diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c5e478ea323..2f872d48ec8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v1b1 pull_request: jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc61754c2ce..a6c4c12d9ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v1b1 pull_request: workflow_dispatch: @@ -32,7 +33,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/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 56179bca2cd..4e9737d0b3b 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v1b1 pull_request: jobs: diff --git a/.github/workflows/typo.yml b/.github/workflows/typo.yml index a11b099c81a..3d147e129a2 100644 --- a/.github/workflows/typo.yml +++ b/.github/workflows/typo.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - v1b1 pull_request: env: diff --git a/creating-capabilities.md b/creating-capabilities.md new file mode 100644 index 00000000000..38342d71d6d --- /dev/null +++ b/creating-capabilities.md @@ -0,0 +1,445 @@ +# Creating capabilities + +This document describes how to create new capabilities and migrate devices to the +`Device`/`Driver`/`CapabilityBackend`/`Capability` architecture. + +## Architecture + +``` +Device (frontend — user-facing API) + ├── _driver: Driver (hardware I/O, lifecycle, device-level ops) + ├── _capabilities: [Capability, ...] + │ └── Capability (frontend logic for one concern) + │ └── backend: CapabilityBackend (protocol translation, uses _driver) +``` + +### Lifecycle + +``` +Device.setup() + → driver.setup() # open connection, initialize hardware + → for cap in capabilities: + cap._on_setup() # Capability._on_setup() + → cap.backend._on_setup() # CapabilityBackend._on_setup() + → cap._setup_finished = True + +Device.stop() + → for cap in reversed(capabilities): + cap._on_stop() # Capability._on_stop() + → cap.backend._on_stop() # CapabilityBackend._on_stop() + → cap._setup_finished = False + → driver.stop() # close connection +``` + +### What goes where + +| Layer | Responsibility | Examples | +|-------|---------------|----------| +| **Driver** | I/O (serial, USB, gRPC), connection lifecycle (`setup`/`stop`), device-level operations that don't fit any capability. Exposes **generic** methods like `send(bytes)`, `send_command(str)`, `run_measurement(payload)`. | `send_command()`, `open_door()`, `reset()`, `home()`, `get_configuration()` | +| **CapabilityBackend** | Protocol translation — encodes capability-level operations into driver commands. Holds capability-specific config. Has `_on_setup`/`_on_stop` hooks for initialization after driver connects. | `start_shaking()` → `driver.send_command("shakeOn")`, objective/filter cube config | +| **Capability** | User-facing API, validation, orchestration, convenience methods | `shake(speed, duration)` → calls backend + sleep + stop | +| **Device** | Wires driver + capabilities. Manages lifecycle. | Creates driver and backends in `__init__`, registers `_capabilities` | + +### Key classes + +| Class | Location | Base | +|-------|----------|------| +| `Driver` | `pylabrobot.device` | `SerializableMixin, ABC` — abstract `setup()`, `stop()` | +| `Device` | `pylabrobot.device` | `SerializableMixin, ABC` — owns `_driver: Driver`, `_capabilities: List[Capability]` | +| `CapabilityBackend` | `pylabrobot.capabilities.capability` | `ABC` — has `_on_setup()`, `_on_stop()` hooks | +| `Capability` | `pylabrobot.capabilities.capability` | `ABC` — owns `backend: CapabilityBackend`, has `_on_setup()`, `_on_stop()` | + +### Common mistakes + +**Driver mirrors capability interface (WRONG):** +```python +class MyDriver(Driver): + async def set_temperature(self, temperature: float): # NO — this is a capability method + self.io.write(f"SET {temperature}") + +class MyTempBackend(TemperatureControllerBackend): + async def set_temperature(self, temperature: float): + await self._driver.set_temperature(temperature) # pointless delegation +``` + +**Backend encodes protocol (RIGHT):** +```python +class MyDriver(Driver): + async def send_command(self, cmd: str): # generic wire method + await self.io.write(cmd.encode()) + +class MyTempBackend(TemperatureControllerBackend): + async def set_temperature(self, temperature: float): + await self._driver.send_command(f"SET {temperature}") # protocol lives here +``` + +The driver is the wire. The backend is the protocol. If a driver method has the same name +as a capability method, something is wrong. + +**Initialization in driver.setup() vs backend._on_setup():** +Hardware-specific init that requires the driver to be connected (e.g. "initialize shaker +drive", "configure objectives") belongs in `CapabilityBackend._on_setup()`, not +`Driver.setup()`. The driver's `setup()` should only open the connection. + +## Creating a new capability + +### 1. Define the capability backend (abstract) + +`pylabrobot/capabilities//backend.py`: + +```python +from abc import ABCMeta, abstractmethod +from pylabrobot.capabilities.capability import CapabilityBackend + +class ShakerBackend(CapabilityBackend, metaclass=ABCMeta): + @abstractmethod + async def start_shaking(self, speed: float): ... + + @abstractmethod + async def stop_shaking(self): ... + + @property + @abstractmethod + def supports_locking(self) -> bool: ... + + @abstractmethod + async def lock_plate(self): ... + + @abstractmethod + async def unlock_plate(self): ... +``` + +One capability, one concern. Only abstract methods for the operations this capability supports. +No `setup()`/`stop()` — use `_on_setup()`/`_on_stop()` (inherited from `CapabilityBackend`) +for initialization that must happen after the driver is connected. + +### 2. Define the capability (frontend) + +`pylabrobot/capabilities//.py`: + +```python +from pylabrobot.capabilities.capability import Capability +from .backend import ShakerBackend + +class Shaker(Capability): + def __init__(self, backend: ShakerBackend): + super().__init__(backend=backend) + self.backend: ShakerBackend = backend # narrow the type + + async def shake(self, speed: float, duration: float = None): + """Convenience: shake for a duration then stop.""" + await self.backend.start_shaking(speed=speed) + if duration: + await asyncio.sleep(duration) + await self.backend.stop_shaking() +``` + +Frontend logic (validation, orchestration, convenience methods) lives here, not in the backend. +The `self.backend: ShakerBackend = backend` line narrows the type from `CapabilityBackend`. + +### 3. Export via `__init__.py` + +`pylabrobot/capabilities//__init__.py`: + +```python +from .backend import ShakerBackend +from . import Shaker +``` + +## Implementing a vendor device + +### Single-capability device + +`pylabrobot//backend.py`: + +```python +from pylabrobot.capabilities.fan_control import FanBackend +from pylabrobot.device import Driver + +class MyFanDriver(Driver): + """Owns the hardware connection. Knows how to send bytes on the wire.""" + + def __init__(self, port: str): + self.io = Serial(port=port, baudrate=9600, ...) + + async def setup(self): + await self.io.setup() + + async def stop(self): + await self.io.stop() + + async def send(self, command: bytes): + """Send raw bytes and read response.""" + await self.io.write(command) + return await self.io.read(64) + + +class MyFanFanBackend(FanBackend): + """Translates FanBackend interface into driver commands. + + This is where protocol encoding lives — the backend knows that + turn_on means sending specific byte sequences via the driver. + """ + + def __init__(self, driver: MyFanDriver): + self._driver = driver + + async def turn_on(self, intensity: int) -> None: + await self._driver.send(b"\x01" + bytes([intensity])) + + async def turn_off(self) -> None: + await self._driver.send(b"\x00") +``` + +`pylabrobot//.py`: + +```python +from pylabrobot.capabilities.fan_control import Fan +from pylabrobot.device import Device +from .backend import MyFanDriver, MyFanFanBackend + +class MyFan(Device): + def __init__(self, port: str): + driver = MyFanDriver(port=port) + super().__init__(driver=driver) + self._driver: MyFanDriver = driver + self.fan = Fan(backend=MyFanFanBackend(driver)) + self._capabilities = [self.fan] +``` + +### Multi-capability device (shared driver) + +When one device supports multiple capabilities, they share a single driver: + +```python +class BioShakeDriver(Driver): + """Serial driver. Owns I/O, device-level ops (reset, home).""" + + def __init__(self, port: str): + self.io = Serial(port=port, baudrate=9600, ...) + + async def setup(self, skip_home: bool = False): + await self.io.setup() + if not skip_home: + await self.reset() + await self.home() + + async def stop(self): + await self.io.stop() + + async def send_command(self, cmd: str) -> Optional[str]: + """Send an ASCII command, return parsed response.""" + ... + + async def reset(self): + """Device-level reset — not a capability.""" + ... + + async def home(self): + """Device-level homing — not a capability.""" + ... + + +class BioShakeShakerBackend(ShakerBackend): + """Encodes shaking protocol using the driver.""" + + def __init__(self, driver: BioShakeDriver): + self._driver = driver + + async def start_shaking(self, speed: float): + await self._driver.send_command(f"setShakeTargetSpeed{int(speed)}") + await self._driver.send_command("shakeOn") + + async def stop_shaking(self): + await self._driver.send_command("shakeOff") + + ... + + +class BioShakeTemperatureBackend(TemperatureControllerBackend): + """Encodes temperature protocol using the same driver.""" + + def __init__(self, driver: BioShakeDriver, supports_active_cooling: bool = False): + self._driver = driver + self._supports_active_cooling = supports_active_cooling + + async def set_temperature(self, temperature: float): + await self._driver.send_command(f"setTempTarget{int(temperature * 10)}") + await self._driver.send_command("tempOn") + + ... + + +class BioShake3000T(PlateHolder, Device): + def __init__(self, name: str, port: str): + driver = BioShakeDriver(port=port) + PlateHolder.__init__(self, name=name, ...) + Device.__init__(self, driver=driver) + self._driver: BioShakeDriver = driver + self.tc = TemperatureController(backend=BioShakeTemperatureBackend(driver)) + self.shaker = Shaker(backend=BioShakeShakerBackend(driver)) + self._capabilities = [self.tc, self.shaker] +``` + +### Backend `_on_setup` / `_on_stop` + +If a backend needs to do work after the driver connects (e.g. query hardware configuration), +override `_on_setup()`: + +```python +class PicoMicroscopyBackend(MicroscopyBackend): + def __init__(self, driver: PicoDriver, objectives=None, filter_cubes=None): + self._driver = driver + self._objectives = objectives or {} + self._filter_cubes = filter_cubes or {} + + async def _on_setup(self): + """Configure objectives and filter cubes after driver connects.""" + for pos, obj in self._objectives.items(): + await self.change_objective(pos, obj) + for pos, mode in self._filter_cubes.items(): + await self.change_filter_cube(pos, mode) +``` + +This is called automatically by `Capability._on_setup()` → `backend._on_setup()` during +`Device.setup()`, after `driver.setup()` has completed. + +### Device-level operations + +Operations that don't fit any capability stay on the driver. Users access them via `_driver`: + +```python +class PicoDriver(Driver): + async def open_door(self): ... + async def close_door(self): ... + async def get_configuration(self) -> dict: ... + +# Usage: +pico = Pico(name="pico", host="192.168.1.100") +await pico.setup() +await pico._driver.open_door() +``` + +### Chatterbox backends (testing) + +Chatterbox backends are pure `CapabilityBackend` subclasses — they do **not** extend `Driver`. +They have no I/O and return dummy data for device-free testing: + +```python +class MyFanChatterboxBackend(FanBackend): + """No-op backend for testing.""" + + async def turn_on(self, intensity: int) -> None: + pass + + async def turn_off(self) -> None: + pass +``` + +To test a capability without a real device, create it directly and call `_on_setup()`: + +```python +async def test_something(self): + backend = MyFanChatterboxBackend() + cap = Fan(backend=backend) + await cap._on_setup() + await cap.turn_on(intensity=50) +``` + +## Naming conventions + +| Thing | Pattern | Example | +|-------|---------|---------| +| Driver | `Driver` | `BioShakeDriver`, `PicoDriver` | +| Capability backend | `Backend` | `BioShakeShakerBackend`, `PicoMicroscopyBackend` | +| Chatterbox backend | `ChatterboxBackend` or `ChatterboxBackend` | `HamiltonHepaFanChatterboxBackend` | +| Capability (abstract) | `Capability` | `Shaker`, `Fan` | +| Capability backend (abstract) | `Backend` | `ShakerBackend`, `FanBackend` | +| Device | `` or product name | `HamiltonHepaFan`, `BioShake3000T`, `Pico` | + +## File layout + +For simple devices, driver and backends can live in one file. For complex devices or +when a vendor directory has multiple devices, split into separate files. + +**Simple (single file):** +``` +pylabrobot// + __init__.py + backend.py # Driver + CapabilityBackend(s) in one file + .py # Device frontend +``` + +**Complex (split):** +``` +pylabrobot// + __init__.py + driver.py # Driver only + _backend.py # one file per CapabilityBackend + .py # Device frontend +``` + +**Capability definitions (always this layout):** +``` +pylabrobot/capabilities// + __init__.py # exports backend + capability + backend.py # abstract CapabilityBackend + .py # Capability frontend +``` + +## Checklist for splitting an existing monolithic backend + +When migrating an existing `class FooBackend(SomeCapabilityBackend, Driver)` to the split +architecture, follow these steps: + +1. **Read the existing backend class.** Identify: I/O setup, generic send/receive methods, + device-level ops (door, reset, home), and capability methods. +2. **Create the Driver.** Move I/O, `setup()`/`stop()`, generic send methods, device-level ops, + and `serialize()`. The driver's `setup()` should only open the connection — move any + capability-specific init to `_on_setup()` on the backend. +3. **Create the CapabilityBackend(s).** Move capability methods. Each backend gets + `__init__(self, driver: FooDriver)` and stores `self._driver = driver`. Protocol encoding + (building command strings, byte payloads) lives here, not on the driver. +4. **Update the Device.** Create driver + backend(s) in `__init__`, wire `_capabilities`. +5. **Update `__init__.py` exports.** Remove old class name, add new driver + backend names. +6. **Update legacy wrappers.** Check `pylabrobot/legacy/` for files that import the old class. + Update them to create driver + backend(s) internally. Run + `rg 'OldClassName' pylabrobot/legacy/` to find them. +7. **Preserve docstrings.** Do not remove existing docstrings when moving methods between classes. +8. **Smoke test.** `python -c "from pylabrobot. import ..."` to verify imports. + +## Making legacy code wrap new code + +When a legacy `Machine`/`MachineBackend` module already exists, the goal is to move the +*implementation* into the new architecture while keeping the legacy API unchanged. + +### Principles + +1. **Legacy types don't change.** The old `MachineBackend` subclass keeps its name and import path. +2. **Implementation moves to new code.** The legacy wrapper creates a driver + backends internally. +3. **`MachineBackend` and `Driver` are independent hierarchies.** Legacy backends never inherit from `Driver`. + +### Pattern + +```python +# pylabrobot/legacy//_backend.py + +from pylabrobot..backend import MyDriver, MyShakerBackend +from pylabrobot.legacy..backend import LegacyShakerBackend + +class LegacyMyDevice(LegacyShakerBackend): + def __init__(self, port: str): + self._driver = MyDriver(port=port) + self._shaker = MyShakerBackend(self._driver) + + async def setup(self): + await self._driver.setup() + await self._shaker._on_setup() + + async def stop(self): + await self._shaker._on_stop() + await self._driver.stop() + + async def start_shaking(self, speed: float): + await self._shaker.start_shaking(speed) +``` 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/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst new file mode 100644 index 00000000000..a4115aff613 --- /dev/null +++ b/docs/api/pylabrobot.agilent.rst @@ -0,0 +1,142 @@ +.. currentmodule:: pylabrobot.agilent + +pylabrobot.agilent package +========================== + +BioTek EL406 +------------ + +.. currentmodule:: pylabrobot.agilent.biotek.el406.el406 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406 + EL406Driver + +.. currentmodule:: pylabrobot.agilent.biotek.el406.plate_washing_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406PlateWasher96Backend + +.. autoclass:: pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWasher96Backend.WashParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWasher96Backend.PrimeParams + :members: + +.. currentmodule:: pylabrobot.agilent.biotek.el406.shaking_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406ShakingBackend + +.. currentmodule:: pylabrobot.agilent.biotek.el406.syringe_dispensing_backend8 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406SyringeDispensingBackend8 + +.. autoclass:: pylabrobot.agilent.biotek.el406.syringe_dispensing_backend8.EL406SyringeDispensingBackend8.DispenseParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.el406.syringe_dispensing_backend8.EL406SyringeDispensingBackend8.PrimeParams + :members: + +.. currentmodule:: pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend8 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406PeristalticDispensingBackend8 + +.. autoclass:: pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend8.EL406PeristalticDispensingBackend8.DispenseParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend8.EL406PeristalticDispensingBackend8.PrimeParams + :members: + + +BioTek Cytation +--------------- + +.. currentmodule:: pylabrobot.agilent.biotek.plate_readers.cytation + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Cytation1 + Cytation5 + CytationMicroscopyBackend + CytationImagingConfig + +.. autoclass:: pylabrobot.agilent.biotek.plate_readers.cytation.microscopy_backend.CytationMicroscopyBackend.CaptureParams + :members: + +.. currentmodule:: pylabrobot.agilent.biotek.loading_tray_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BioTekLoadingTrayBackend + +.. autoclass:: pylabrobot.agilent.biotek.loading_tray_backend.BioTekLoadingTrayBackend.OpenParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.loading_tray_backend.BioTekLoadingTrayBackend.CloseParams + :members: + + +BioTek Synergy H1 +------------------ + +.. currentmodule:: pylabrobot.agilent.biotek.plate_readers.synergy + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SynergyH1 + SynergyH1Backend + +.. autoclass:: pylabrobot.agilent.biotek.plate_readers.base.BioTekBackend.LuminescenceParams + :members: + + +VSpin +----- + +.. currentmodule:: pylabrobot.agilent.vspin + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + VSpin + VSpinDriver + VSpinCentrifugeBackend + Access2 + Access2Driver + +.. autoclass:: pylabrobot.agilent.vspin.VSpinCentrifugeBackend.SpinParams + :members: diff --git a/docs/api/pylabrobot.arms.rst b/docs/api/pylabrobot.arms.rst new file mode 100644 index 00000000000..60a7050203d --- /dev/null +++ b/docs/api/pylabrobot.arms.rst @@ -0,0 +1,42 @@ +.. currentmodule:: pylabrobot.arms + +pylabrobot.arms package +======================= + +Arm capabilities for picking up, moving, and placing labware. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + arm.GripperArm + orientable_arm.OrientableArm + + +Backends +-------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + backend.GripperArmBackend + backend.OrientableGripperArmBackend + backend.ArticulatedGripperArmBackend + backend.CanFreedrive + backend.HasJoints + backend.CanGrip + + +Types +----- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + standard.GripperLocation + standard.GripDirection diff --git a/docs/api/pylabrobot.azenta.rst b/docs/api/pylabrobot.azenta.rst new file mode 100644 index 00000000000..9012ce85723 --- /dev/null +++ b/docs/api/pylabrobot.azenta.rst @@ -0,0 +1,47 @@ +.. currentmodule:: pylabrobot.azenta + +pylabrobot.azenta package +========================= + +a4S Sealer +---------- + +.. currentmodule:: pylabrobot.azenta.a4s + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + A4S + A4SDriver + A4SSealerBackend + A4STemperatureBackend + 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 + XPeelDriver + XPeelPeelerBackend + +.. autoclass:: pylabrobot.azenta.xpeel.XPeelPeelerBackend.PeelParams + :members: diff --git a/docs/api/pylabrobot.bmg_labtech.rst b/docs/api/pylabrobot.bmg_labtech.rst new file mode 100644 index 00000000000..6801505702e --- /dev/null +++ b/docs/api/pylabrobot.bmg_labtech.rst @@ -0,0 +1,53 @@ +.. currentmodule:: pylabrobot.bmg_labtech + +pylabrobot.bmg_labtech package +============================== + +CLARIOstar +---------- + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.clariostar + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstar + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarDriver + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.absorbance_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarAbsorbanceBackend + CLARIOstarAbsorbanceParams + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.fluorescence_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarFluorescenceBackend + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.luminescence_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarLuminescenceBackend diff --git a/docs/api/pylabrobot.brooks.rst b/docs/api/pylabrobot.brooks.rst new file mode 100644 index 00000000000..fed06643ca6 --- /dev/null +++ b/docs/api/pylabrobot.brooks.rst @@ -0,0 +1,31 @@ +.. currentmodule:: pylabrobot.brooks + +pylabrobot.brooks package +========================= + +PreciseFlex +----------- + +.. currentmodule:: pylabrobot.brooks.precise_flex + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PreciseFlex400 + PreciseFlexDriver + PreciseFlexArmBackend + PreciseFlex3400Backend + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.PickUpParams + :members: + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.DropParams + :members: + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.MoveToJointPositionParams + :members: + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.MoveToLocationParams + :members: diff --git a/docs/api/pylabrobot.byonoy.rst b/docs/api/pylabrobot.byonoy.rst new file mode 100644 index 00000000000..d8b47ad444a --- /dev/null +++ b/docs/api/pylabrobot.byonoy.rst @@ -0,0 +1,46 @@ +.. currentmodule:: pylabrobot.byonoy + +pylabrobot.byonoy package +========================= + +Absorbance 96 +------------- + +.. currentmodule:: pylabrobot.byonoy.absorbance_96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ByonoyAbsorbance96 + ByonoyAbsorbance96Backend + ByonoyAbsorbanceBaseUnit + 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 + ByonoyLuminescence96Backend + ByonoyLuminescenceBaseUnit + byonoy_l96 + byonoy_l96_base_unit + byonoy_l96_reader_unit + byonoy_l96a + byonoy_l96a_base_unit + byonoy_l96a_reader_unit + +.. autoclass:: pylabrobot.byonoy.luminescence_96.ByonoyLuminescence96Backend.LuminescenceParams + :members: diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst new file mode 100644 index 00000000000..666ce292e20 --- /dev/null +++ b/docs/api/pylabrobot.capabilities.rst @@ -0,0 +1,356 @@ +.. currentmodule:: pylabrobot.capabilities + +pylabrobot.capabilities package +=============================== + +Base classes for the capability system. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + capability.Capability + capability.CapabilityBackend + capability.BackendParams + + +Temperature Control +------------------- + +.. currentmodule:: pylabrobot.capabilities.temperature_controlling.temperature_controller + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TemperatureController + TemperatureControllerBackend + + +Shaking +------- + +.. currentmodule:: pylabrobot.capabilities.shaking.shaking + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Shaker + ShakerBackend + + +Fan Control +----------- + +.. currentmodule:: pylabrobot.capabilities.fan_control.fan_control + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Fan + FanBackend + + +Humidity Control +---------------- + +.. currentmodule:: pylabrobot.capabilities.humidity_controlling.humidity_controller + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HumidityController + HumidityControllerBackend + + +Centrifuging +------------ + +.. currentmodule:: pylabrobot.capabilities.centrifuging.centrifuging + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Centrifuge + CentrifugeBackend + + +Sealing +------- + +.. currentmodule:: pylabrobot.capabilities.sealing.sealing + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Sealer + SealerBackend + + +Peeling +------- + +.. currentmodule:: pylabrobot.capabilities.peeling.peeling + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Peeler + PeelerBackend + + +Loading Tray +------------ + +.. currentmodule:: pylabrobot.capabilities.loading_tray.loading_tray + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + LoadingTray + +.. currentmodule:: pylabrobot.capabilities.loading_tray.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + LoadingTrayBackend + + +Tilting +------- + +.. currentmodule:: pylabrobot.capabilities.tilting.tilting + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Tilter + TilterBackend + + +Pumping +------- + +.. currentmodule:: pylabrobot.capabilities.pumping.pumping + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Pump + PumpBackend + PumpCalibration + + +Weighing +-------- + +.. currentmodule:: pylabrobot.capabilities.weighing.weighing + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Scale + ScaleBackend + + +Barcode Scanning +---------------- + +.. currentmodule:: pylabrobot.capabilities.barcode_scanning.barcode_scanning + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BarcodeScanner + BarcodeScannerBackend + + +Microscopy +---------- + +.. currentmodule:: pylabrobot.capabilities.microscopy.microscopy + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Microscopy + MicroscopyBackend + + +Automated Retrieval +------------------- + +.. currentmodule:: pylabrobot.capabilities.automated_retrieval.automated_retrieval + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + AutomatedRetrieval + AutomatedRetrievalBackend + + +Plate Reading - Absorbance +-------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.absorbance.absorbance + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Absorbance + AbsorbanceBackend + + +Plate Reading - Fluorescence +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.fluorescence.fluorescence + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Fluorescence + FluorescenceBackend + + +Plate Reading - Luminescence +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.luminescence.luminescence + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Luminescence + LuminescenceBackend + + +Devices +------- + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombi + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiDriver + + +Bulk Dispensing - Peristaltic +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispensing8 + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispensingBackend8 + + +Bulk Dispensing - Syringe +------------------------- + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.syringe + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispensing8 + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispensingBackend8 + + +Liquid Handling - PIP (Independent Channels) +-------------------------------------------- + +.. currentmodule:: pylabrobot.capabilities.liquid_handling.pip + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PIP + PIPBackend + + +Liquid Handling - Head96 (96-Channel Head) +------------------------------------------ + +.. currentmodule:: pylabrobot.capabilities.liquid_handling.head96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Head96 + Head96Backend diff --git a/docs/api/pylabrobot.centrifuge.rst b/docs/api/pylabrobot.centrifuge.rst deleted file mode 100644 index df0932e8e22..00000000000 --- a/docs/api/pylabrobot.centrifuge.rst +++ /dev/null @@ -1,24 +0,0 @@ -.. currentmodule:: pylabrobot.centrifuge - -pylabrobot.centrifuge package -================================ - -This package contains APIs for working with centrifuges. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - centrifuge.Centrifuge - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - vspin_backend.VSpinBackend diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst new file mode 100644 index 00000000000..a9ac7e4848e --- /dev/null +++ b/docs/api/pylabrobot.hamilton.rst @@ -0,0 +1,136 @@ +.. currentmodule:: pylabrobot.hamilton + +pylabrobot.hamilton package +=========================== + +Heater Cooler +------------- + +.. currentmodule:: pylabrobot.hamilton.heater_cooler + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HamiltonHeaterCooler + HamiltonHeaterCoolerDriver + HamiltonHeaterCoolerTemperatureBackend + + +HEPA Fan +-------- + +.. currentmodule:: pylabrobot.hamilton.only_fans + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HamiltonHepaFan + HamiltonHepaFanDriver + HamiltonHepaFanFanBackend + HamiltonHepaFanChatterboxBackend + + +Heater Shaker +------------- + +.. currentmodule:: pylabrobot.hamilton.heater_shaker + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HamiltonHeaterShaker + HamiltonHeaterShakerBackend + HamiltonHeaterShakerBox + + +STAR Liquid Handler +------------------- + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.star + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + STAR + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.star.pip_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + STARPIPBackend + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.PickUpTipsParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.DropTipsParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.AspirateParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.DispenseParams + :members: + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.star.pip_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + LLDMode + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.star.head96_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + STARHead96Backend + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.PickUpTips96Params + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.DropTips96Params + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.Aspirate96Params + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.Dispense96Params + :members: + +.. currentmodule:: pylabrobot.hamilton.liquid_handlers.star.iswap + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + iSWAPBackend + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.ParkParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.CloseGripperParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.PickUpParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.DropParams + :members: + +.. autoclass:: pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.MoveToLocationParams + :members: 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.inheco.rst b/docs/api/pylabrobot.inheco.rst new file mode 100644 index 00000000000..8100f0702b9 --- /dev/null +++ b/docs/api/pylabrobot.inheco.rst @@ -0,0 +1,71 @@ +.. 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 + InhecoThermoshakeBackend + inheco_thermoshake + inheco_thermoshake_ac + inheco_thermoshake_rm + + +CPAC +---- + +.. currentmodule:: pylabrobot.inheco.cpac + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoCPAC + InhecoCPACBackend + inheco_cpac_ultraflat + + +SCILA +----- + +.. currentmodule:: pylabrobot.inheco.scila.scila + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SCILA + +.. currentmodule:: pylabrobot.inheco.scila.scila_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SCILADriver + SCILATemperatureBackend 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.liconic.rst b/docs/api/pylabrobot.liconic.rst new file mode 100644 index 00000000000..100674d385b --- /dev/null +++ b/docs/api/pylabrobot.liconic.rst @@ -0,0 +1,22 @@ +.. currentmodule:: pylabrobot.liconic + +pylabrobot.liconic package +========================== + +.. currentmodule:: pylabrobot.liconic.liconic + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Liconic + +.. currentmodule:: pylabrobot.liconic.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + LiconicBackend 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..8efa5ef2424 --- /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: + + MettlerToledoWXS205SDUDriver + MettlerToledoWXS205SDUScaleBackend + MettlerToledoError diff --git a/docs/api/pylabrobot.molecular_devices.rst b/docs/api/pylabrobot.molecular_devices.rst new file mode 100644 index 00000000000..97e144b6ca3 --- /dev/null +++ b/docs/api/pylabrobot.molecular_devices.rst @@ -0,0 +1,57 @@ +.. currentmodule:: pylabrobot.molecular_devices + +pylabrobot.molecular\_devices package +===================================== + +SpectraMax +---------- + +.. currentmodule:: pylabrobot.molecular_devices.spectramax + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SpectraMaxM5 + SpectraMax384Plus + MolecularDevicesDriver + MolecularDevicesAbsorbanceBackend + SpectraMax384PlusAbsorbanceBackend + SpectraMaxM5FluorescenceBackend + SpectraMaxM5LuminescenceBackend + MolecularDevicesTemperatureBackend + MolecularDevicesSettings + ReadMode + ReadType + ReadOrder + Calibrate + CarriageSpeed + PmtGain + ShakeSettings + KineticSettings + SpectrumSettings + +.. autoclass:: pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesAbsorbanceBackend.AbsorbanceParams + :members: + +.. autoclass:: pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5FluorescenceBackend.FluorescenceParams + :members: + +.. autoclass:: pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5LuminescenceBackend.LuminescenceParams + :members: + + +ImageXpress Pico +---------------- + +.. currentmodule:: pylabrobot.molecular_devices.imageXpress.pico + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Pico + PicoDriver + PicoMicroscopyBackend 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.opentrons.rst b/docs/api/pylabrobot.opentrons.rst new file mode 100644 index 00000000000..63a24cd2be1 --- /dev/null +++ b/docs/api/pylabrobot.opentrons.rst @@ -0,0 +1,20 @@ +.. currentmodule:: pylabrobot.opentrons + +pylabrobot.opentrons package +============================ + +Temperature Module +------------------ + +.. currentmodule:: pylabrobot.opentrons.temperature_module + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + OpentronsTemperatureModuleV2 + OpentronsTemperatureModuleDriver + OpentronsTemperatureModuleTemperatureBackend + OpentronsTemperatureModuleUSBDriver + OpentronsTemperatureModuleUSBTemperatureBackend diff --git a/docs/api/pylabrobot.plate_reading.rst b/docs/api/pylabrobot.plate_reading.rst deleted file mode 100644 index cde365b0fbc..00000000000 --- a/docs/api/pylabrobot.plate_reading.rst +++ /dev/null @@ -1,29 +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 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..aaa74b54363 --- /dev/null +++ b/docs/api/pylabrobot.qinstruments.rst @@ -0,0 +1,28 @@ +.. currentmodule:: pylabrobot.qinstruments + +pylabrobot.qinstruments package +=============================== + +.. currentmodule:: pylabrobot.qinstruments.bioshake + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BioShake + BioShakeDriver + BioShakeShakerBackend + BioShakeTemperatureBackend + 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..53a04635937 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -3,25 +3,46 @@ API === +Core +---- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + device.Device + + Subpackages ----------- .. toctree:: :maxdepth: 1 + pylabrobot.capabilities + pylabrobot.arms 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.bmg_labtech + pylabrobot.brooks + pylabrobot.byonoy + pylabrobot.hamilton + pylabrobot.inheco + pylabrobot.liconic + pylabrobot.mettler_toledo + pylabrobot.molecular_devices + pylabrobot.opentrons + pylabrobot.qinstruments + pylabrobot.tecan + pylabrobot.thermo_fisher 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.tecan.rst b/docs/api/pylabrobot.tecan.rst new file mode 100644 index 00000000000..175fe540c77 --- /dev/null +++ b/docs/api/pylabrobot.tecan.rst @@ -0,0 +1,55 @@ +.. currentmodule:: pylabrobot.tecan + +pylabrobot.tecan package +======================== + +Infinite 200 PRO +----------------- + +.. currentmodule:: pylabrobot.tecan.infinite.infinite + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TecanInfinite200Pro + +.. currentmodule:: pylabrobot.tecan.infinite.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TecanInfiniteDriver + +.. currentmodule:: pylabrobot.tecan.infinite.absorbance_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TecanInfiniteAbsorbanceBackend + TecanInfiniteAbsorbanceParams + +.. currentmodule:: pylabrobot.tecan.infinite.fluorescence_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TecanInfiniteFluorescenceBackend + TecanInfiniteFluorescenceParams + +.. currentmodule:: pylabrobot.tecan.infinite.luminescence_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TecanInfiniteLuminescenceBackend + TecanInfiniteLuminescenceParams 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..a5dbaf81bbd --- /dev/null +++ b/docs/api/pylabrobot.thermo_fisher.rst @@ -0,0 +1,114 @@ +.. currentmodule:: pylabrobot.thermo_fisher + +pylabrobot.thermo_fisher package +================================ + +Multidrop Combi +--------------- + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombi + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiDriver + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiPeristalticDispensingBackend8 + +.. autoclass:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8.MultidropCombiPeristalticDispensingBackend8.DispenseParams + :members: + +.. autoclass:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8.MultidropCombiPeristalticDispensingBackend8.PrimeParams + :members: + +.. autoclass:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8.MultidropCombiPeristalticDispensingBackend8.PurgeParams + :members: + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.enums + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CassetteType + DispensingOrder + PrimeMode + EmptyMode + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.helpers + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + plate_to_type_index + plate_to_pla_params + + +Cytomat +------- + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.cytomat + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Cytomat + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CytomatBackend + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.chatterbox + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CytomatChatterbox + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.heraeus_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HeraeusCytomatBackend + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.constants + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CytomatType 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 5e4758239d3..d5676a7e9b2 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 = { 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/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 7f406e2a5b8..82f30512712 100644 --- a/docs/resources/index.md +++ b/docs/resources/index.md @@ -93,14 +93,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) 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/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index 3de4229f65b..d976603d54e 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -14,12 +14,10 @@ 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 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/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/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/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/01_material-handling/_material-handling.rst b/docs/user_guide/01_material-handling/_material-handling.rst index 68091a72dfe..6a25b08344e 100644 --- a/docs/user_guide/01_material-handling/_material-handling.rst +++ b/docs/user_guide/01_material-handling/_material-handling.rst @@ -11,10 +11,6 @@ This includes machines for temperature and motion control (as neither machines p .. toctree:: :maxdepth: 1 - arms/_arm - centrifuge/_centrifuge - heating_shaking/heating_shaking - fans/fans sealers/sealers temperature-controllers/temperature-controllers storage/storage 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 d6e13a1191b..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md +++ /dev/null @@ -1,24 +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 to Bucket 1. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate to Bucket 2. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.rotate_distance`: Rotate the buckets a specified distance (8000 = 360 degrees). -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle`: Start centrifuge spin cycle. - -PLR supports the following centrifuges: - -```{toctree} -:maxdepth: 1 - -agilent_vspin -``` 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/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/sealers.md b/docs/user_guide/01_material-handling/sealers/sealers.md index 3283f401992..f3a8a68a930 100644 --- a/docs/user_guide/01_material-handling/sealers/sealers.md +++ b/docs/user_guide/01_material-handling/sealers/sealers.md @@ -55,5 +55,4 @@ They do **not** use heat, making them faster and simpler for certain workflows. :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/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 index 698d153b308..dc540325d53 100644 --- a/docs/user_guide/01_material-handling/storage/storage.rst +++ b/docs/user_guide/01_material-handling/storage/storage.rst @@ -134,12 +134,3 @@ Combined Retrieval & Access Summary ------------------------------------------ - -.. 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/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 index 96ccb19511d..b22bef32c4e 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst +++ b/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst @@ -58,13 +58,13 @@ Implementation Backend ^^^^^^^ -PyLabRobot programmatically defines Temperature Controller machines based on the :class:`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController` base class. +PyLabRobot programmatically defines Temperature Controller machines based on the :class:`~pylabrobot.legacy.temperature_controlling.temperature_controller.TemperatureController` base class. e.g.: .. code-block:: python - from pylabrobot.temperature_controlling.temperature_controller import ( + from pylabrobot.legacy.temperature_controlling.temperature_controller import ( TemperatureControllerBackend ) @@ -123,7 +123,3 @@ e.g.: .. 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 index 9d76b3231a5..daa7e4bfca8 100644 --- a/docs/user_guide/01_material-handling/thermocycling/thermocycling.md +++ b/docs/user_guide/01_material-handling/thermocycling/thermocycling.md @@ -15,8 +15,3 @@ Thermocyclers are essential for temperature-controlled processes like PCR (Polym - Opentrons Thermocycler -```{toctree} -:maxdepth: 1 - -Inheco ODTC -``` 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/cytation.ipynb b/docs/user_guide/02_analytical/plate-reading/cytation.ipynb deleted file mode 100644 index 2958577ed71..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": "iVBORw0KGgoAAAANSUhEUgAAAhYAAAF2CAYAAAAyW9EUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAZkUlEQVR4nO3df5CVdf338feyK2cRl02QXxuLolkIiKEIg1hqogy3OlkzVg4W4YxNzpIiU6Nbo9ZtumqTYyqDP8awmcQfzYSad+ogKY53oghtt2aiJOUKApm6CxRH3D33H99pv+1XcTnLZ7n2Wh+PmeuPc7iO12uO4T4758CpKJVKpQAASGBA1gMAgP5DWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDJV+/uCHR0dsXnz5qipqYmKior9fXkAoAdKpVJs37496urqYsCAPb8usd/DYvPmzVFfX7+/LwsAJNDS0hJjxozZ46/v97CoqamJiIi/rTsshhzUd9+J+X/FYtYT9sr8dd/IekK36g5uy3pCtzatrct6wl755HGbs57QrbdWfDLrCf1G9T/6/jcu7BrW9195PviV3VlP2CvvfPqArCd8pPb3dsWrt/3vzp/je7Lfw+Lfb38MOWhADKnpu2Fx0MC+u+0/VR5YnfWEblUN7vuRNqC67z+PERFVgwtZT+hWZSEfz2UeVA7s+2FRWej7YVF1QGXWE/ZKZaFvh8W/dfcxhnz89AQAckFYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkehQWixcvjsMOOyyqq6tj+vTp8dxzz6XeBQDkUNlhcd9998WiRYviyiuvjHXr1sUxxxwTs2fPjm3btvXGPgAgR8oOixtuuCEuuOCCmD9/fkyYMCFuvfXWOPDAA+PnP/95b+wDAHKkrLB47733Yu3atTFr1qz//gcMGBCzZs2KZ5555kMfUywWo62trcsBAPRPZYXFW2+9Fe3t7TFy5Mgu948cOTK2bNnyoY9pamqK2trazqO+vr7nawGAPq3X/1RIY2NjtLa2dh4tLS29fUkAICNV5Zx8yCGHRGVlZWzdurXL/Vu3bo1Ro0Z96GMKhUIUCoWeLwQAcqOsVywGDhwYxx13XKxcubLzvo6Ojli5cmXMmDEj+TgAIF/KesUiImLRokUxb968mDp1akybNi1uvPHG2LlzZ8yfP7839gEAOVJ2WHz1q1+Nv//973HFFVfEli1b4rOf/Ww8+uijH/hAJwDw8VN2WERELFiwIBYsWJB6CwCQc74rBABIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGR69O2mKfyv78yLqgOqs7p8v3HY1n9lPaFbm08ak/WEbh2+qi3rCXtl1/8dmfWEbtVt7fvP5dsTa7KesFeG/ml71hP6hV0jB2U9Ya8MfXl31hM+0vu7926fVywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZMoOi6eeeirOOuusqKuri4qKinjggQd6YRYAkEdlh8XOnTvjmGOOicWLF/fGHgAgx6rKfcCcOXNizpw5vbEFAMi5ssOiXMViMYrFYufttra23r4kAJCRXv/wZlNTU9TW1nYe9fX1vX1JACAjvR4WjY2N0dra2nm0tLT09iUBgIz0+lshhUIhCoVCb18GAOgD/D0WAEAyZb9isWPHjtiwYUPn7Y0bN0Zzc3MMHTo0xo4dm3QcAJAvZYfF888/H6ecckrn7UWLFkVExLx58+Kuu+5KNgwAyJ+yw+Lkk0+OUqnUG1sAgJzzGQsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSKfvbTVP517CqqByY2eX7jbfHD8l6QreGvrw76wnQxYFvvZ/1hH5j18hBWU/oNwY3b8p6wkd6v6O4V+d5xQIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDJlhUVTU1Mcf/zxUVNTEyNGjIizzz471q9f31vbAICcKSssVq1aFQ0NDbF69epYsWJF7N69O04//fTYuXNnb+0DAHKkqpyTH3300S6377rrrhgxYkSsXbs2Pv/5zycdBgDkT1lh8T+1trZGRMTQoUP3eE6xWIxisdh5u62tbV8uCQD0YT3+8GZHR0csXLgwZs6cGZMmTdrjeU1NTVFbW9t51NfX9/SSAEAf1+OwaGhoiBdffDHuvffejzyvsbExWltbO4+WlpaeXhIA6ON69FbIggUL4uGHH46nnnoqxowZ85HnFgqFKBQKPRoHAORLWWFRKpXiO9/5TixfvjyefPLJGDduXG/tAgByqKywaGhoiGXLlsWDDz4YNTU1sWXLloiIqK2tjUGDBvXKQAAgP8r6jMWSJUuitbU1Tj755Bg9enTncd999/XWPgAgR8p+KwQAYE98VwgAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJlPXtpikd/PL2qKrcndXlu7Vr5KCsJ+yV4Y9vynpCt9pHD816Qr9RvfVfWU/o1tsTa7Ke0G8Mbn476wndGvxm1gu6l5f/BvX1ne3tuyI2d3+eVywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACRTVlgsWbIkJk+eHEOGDIkhQ4bEjBkz4pFHHumtbQBAzpQVFmPGjIlrr7021q5dG88//3x84QtfiC9+8Yvxpz/9qbf2AQA5UlXOyWeddVaX21dffXUsWbIkVq9eHRMnTkw6DADIn7LC4j+1t7fHr371q9i5c2fMmDFjj+cVi8UoFoudt9va2np6SQCgjyv7w5svvPBCHHTQQVEoFOLb3/52LF++PCZMmLDH85uamqK2trbzqK+v36fBAEDfVXZYfOYzn4nm5uZ49tln48ILL4x58+bFSy+9tMfzGxsbo7W1tfNoaWnZp8EAQN9V9lshAwcOjE996lMREXHcccfFmjVr4mc/+1ncdtttH3p+oVCIQqGwbysBgFzY57/HoqOjo8tnKACAj6+yXrFobGyMOXPmxNixY2P79u2xbNmyePLJJ+Oxxx7rrX0AQI6UFRbbtm2Lb3zjG/Hmm29GbW1tTJ48OR577LE47bTTemsfAJAjZYXFnXfe2Vs7AIB+wHeFAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkExZ326aUuWWd6JyQCGry3erOoZmPWGvtI/Ox86+btfIQVlP2CuDmzdlPaFbB+bguXx7/AFZT4APqHzz7awnfKRSR3GvzvOKBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJDMPoXFtddeGxUVFbFw4cJEcwCAPOtxWKxZsyZuu+22mDx5cso9AECO9SgsduzYEXPnzo077rgjDj744NSbAICc6lFYNDQ0xBlnnBGzZs3q9txisRhtbW1dDgCgf6oq9wH33ntvrFu3LtasWbNX5zc1NcWPfvSjsocBAPlT1isWLS0tcfHFF8fdd98d1dXVe/WYxsbGaG1t7TxaWlp6NBQA6PvKesVi7dq1sW3btjj22GM772tvb4+nnnoqbrnlligWi1FZWdnlMYVCIQqFQpq1AECfVlZYnHrqqfHCCy90uW/+/Pkxfvz4uPTSSz8QFQDAx0tZYVFTUxOTJk3qct/gwYNj2LBhH7gfAPj48TdvAgDJlP2nQv6nJ598MsEMAKA/8IoFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyezzt5v21M6j66LqgOqsLt+twc2bsp7Qb/x91qFZT+jWgW+9n/WEvdI+emjWE7pVvfVfWU/o1uj/sybrCXulfeqkrCd0q/LNt7Oe0K08bIzo+7+/29t3RWzu/jyvWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASKassPjhD38YFRUVXY7x48f31jYAIGeqyn3AxIkT4/HHH//vf0BV2f8IAKCfKrsKqqqqYtSoUb2xBQDIubI/Y/Hqq69GXV1dHH744TF37tx4/fXXP/L8YrEYbW1tXQ4AoH8qKyymT58ed911Vzz66KOxZMmS2LhxY3zuc5+L7du37/ExTU1NUVtb23nU19fv82gAoG8qKyzmzJkT55xzTkyePDlmz54dv/3tb+Pdd9+N+++/f4+PaWxsjNbW1s6jpaVln0cDAH3TPn3y8hOf+ER8+tOfjg0bNuzxnEKhEIVCYV8uAwDkxD79PRY7duyIv/zlLzF69OhUewCAHCsrLL773e/GqlWr4q9//Wv8/ve/jy996UtRWVkZ5557bm/tAwBypKy3Qt54440499xz4x//+EcMHz48TjzxxFi9enUMHz68t/YBADlSVljce++9vbUDAOgHfFcIAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyZT17aYpVf/9X1FVWcrq8t3a+dlPZj1hrwxu3pT1hG4Nf/xvWU9gP3rp8jFZT+jWhDfrsp6wd958O+sF7EeVffzfd6mjuFfnecUCAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAyZYfFpk2b4rzzzothw4bFoEGD4uijj47nn3++N7YBADlTVc7J77zzTsycOTNOOeWUeOSRR2L48OHx6quvxsEHH9xb+wCAHCkrLK677rqor6+PpUuXdt43bty45KMAgHwq662Qhx56KKZOnRrnnHNOjBgxIqZMmRJ33HHHRz6mWCxGW1tblwMA6J/KCovXXnstlixZEkceeWQ89thjceGFF8ZFF10Uv/jFL/b4mKampqitre086uvr93k0ANA3lRUWHR0dceyxx8Y111wTU6ZMiW9961txwQUXxK233rrHxzQ2NkZra2vn0dLSss+jAYC+qaywGD16dEyYMKHLfUcddVS8/vrre3xMoVCIIUOGdDkAgP6prLCYOXNmrF+/vst9r7zyShx66KFJRwEA+VRWWFxyySWxevXquOaaa2LDhg2xbNmyuP3226OhoaG39gEAOVJWWBx//PGxfPnyuOeee2LSpElx1VVXxY033hhz587trX0AQI6U9fdYRESceeaZceaZZ/bGFgAg53xXCACQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgmbK/Nj2Vyi3vROWAQlaX797IT2a9YK+0jx6a9YRu7Ro5KOsJ/cbg5k1ZT+jWob8pZT0BcmnnZ/v2z533d++K2Nz9eV6xAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQTFlhcdhhh0VFRcUHjoaGht7aBwDkSFU5J69Zsyba29s7b7/44otx2mmnxTnnnJN8GACQP2WFxfDhw7vcvvbaa+OII46Ik046KekoACCfygqL//Tee+/FL3/5y1i0aFFUVFTs8bxisRjFYrHzdltbW08vCQD0cT3+8OYDDzwQ7777bnzzm9/8yPOampqitra286ivr+/pJQGAPq7HYXHnnXfGnDlzoq6u7iPPa2xsjNbW1s6jpaWlp5cEAPq4Hr0V8re//S0ef/zx+PWvf93tuYVCIQqFQk8uAwDkTI9esVi6dGmMGDEizjjjjNR7AIAcKzssOjo6YunSpTFv3ryoqurxZz8BgH6o7LB4/PHH4/XXX4/zzz+/N/YAADlW9ksOp59+epRKpd7YAgDknO8KAQCSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJ7PfvPf/3F5i93/He/r50Wd7fvSvrCXvl/fa+v/P93RVZT+g33u8oZj2hW3n4vZOH55GPn77+e+f99/9rX3dfRFpR2s9fVfrGG29EfX39/rwkAJBIS0tLjBkzZo+/vt/DoqOjIzZv3hw1NTVRUbHv/0+2ra0t6uvro6WlJYYMGZJg4ceX5zIdz2Uansd0PJfpfFyfy1KpFNu3b4+6uroYMGDPn6TY72+FDBgw4CNLp6eGDBnysfoX3Js8l+l4LtPwPKbjuUzn4/hc1tbWdnuOD28CAMkICwAgmdyHRaFQiCuvvDIKhULWU3LPc5mO5zINz2M6nst0PJcfbb9/eBMA6L9y/4oFANB3CAsAIBlhAQAkIywAgGRyHxaLFy+Oww47LKqrq2P69Onx3HPPZT0pd5qamuL444+PmpqaGDFiRJx99tmxfv36rGfl3rXXXhsVFRWxcOHCrKfk0qZNm+K8886LYcOGxaBBg+Loo4+O559/PutZudLe3h6XX355jBs3LgYNGhRHHHFEXHXVVd1+1wMRTz31VJx11llRV1cXFRUV8cADD3T59VKpFFdccUWMHj06Bg0aFLNmzYpXX301m7F9TK7D4r777otFixbFlVdeGevWrYtjjjkmZs+eHdu2bct6Wq6sWrUqGhoaYvXq1bFixYrYvXt3nH766bFz586sp+XWmjVr4rbbbovJkydnPSWX3nnnnZg5c2YccMAB8cgjj8RLL70UP/3pT+Pggw/OelquXHfddbFkyZK45ZZb4s9//nNcd911cf3118fNN9+c9bQ+b+fOnXHMMcfE4sWLP/TXr7/++rjpppvi1ltvjWeffTYGDx4cs2fPjl27+vYXie0XpRybNm1aqaGhofN2e3t7qa6urtTU1JThqvzbtm1bKSJKq1atynpKLm3fvr105JFHllasWFE66aSTShdffHHWk3Ln0ksvLZ144olZz8i9M844o3T++ed3ue/LX/5yae7cuRktyqeIKC1fvrzzdkdHR2nUqFGln/zkJ533vfvuu6VCoVC65557MljYt+T2FYv33nsv1q5dG7Nmzeq8b8CAATFr1qx45plnMlyWf62trRERMXTo0IyX5FNDQ0OcccYZXf63SXkeeuihmDp1apxzzjkxYsSImDJlStxxxx1Zz8qdE044IVauXBmvvPJKRET88Y9/jKeffjrmzJmT8bJ827hxY2zZsqXL7/Ha2tqYPn26nz+RwZeQpfLWW29Fe3t7jBw5ssv9I0eOjJdffjmjVfnX0dERCxcujJkzZ8akSZOynpM79957b6xbty7WrFmT9ZRce+2112LJkiWxaNGi+P73vx9r1qyJiy66KAYOHBjz5s3Lel5uXHbZZdHW1hbjx4+PysrKaG9vj6uvvjrmzp2b9bRc27JlS0TEh/78+fevfZzlNizoHQ0NDfHiiy/G008/nfWU3GlpaYmLL744VqxYEdXV1VnPybWOjo6YOnVqXHPNNRERMWXKlHjxxRfj1ltvFRZluP/+++Puu++OZcuWxcSJE6O5uTkWLlwYdXV1nkd6TW7fCjnkkEOisrIytm7d2uX+rVu3xqhRozJalW8LFiyIhx9+OJ544ole+Wr7/m7t2rWxbdu2OPbYY6Oqqiqqqqpi1apVcdNNN0VVVVW0t7dnPTE3Ro8eHRMmTOhy31FHHRWvv/56Rovy6Xvf+15cdtll8bWvfS2OPvro+PrXvx6XXHJJNDU1ZT0t1/79M8bPnw+X27AYOHBgHHfccbFy5crO+zo6OmLlypUxY8aMDJflT6lUigULFsTy5cvjd7/7XYwbNy7rSbl06qmnxgsvvBDNzc2dx9SpU2Pu3LnR3NwclZWVWU/MjZkzZ37gjzy/8sorceihh2a0KJ/++c9/xoABXf8zX1lZGR0dHRkt6h/GjRsXo0aN6vLzp62tLZ599lk/fyLnb4UsWrQo5s2bF1OnTo1p06bFjTfeGDt37oz58+dnPS1XGhoaYtmyZfHggw9GTU1N53uEtbW1MWjQoIzX5UdNTc0HPpcyePDgGDZsmM+rlOmSSy6JE044Ia655pr4yle+Es8991zcfvvtcfvtt2c9LVfOOuusuPrqq2Ps2LExceLE+MMf/hA33HBDnH/++VlP6/N27NgRGzZs6Ly9cePGaG5ujqFDh8bYsWNj4cKF8eMf/ziOPPLIGDduXFx++eVRV1cXZ599dnaj+4qs/1jKvrr55ptLY8eOLQ0cOLA0bdq00urVq7OelDsR8aHH0qVLs56We/64ac/95je/KU2aNKlUKBRK48ePL91+++1ZT8qdtra20sUXX1waO3Zsqbq6unT44YeXfvCDH5SKxWLW0/q8J5544kP/uzhv3rxSqfRff+T08ssvL40cObJUKBRKp556amn9+vXZju4jfG06AJBMbj9jAQD0PcICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgmf8PALCxEovI6RsAAAAASUVORK5CYII=", - "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](https://docs.pylabrobot.org/user_guide/installation.html#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/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 index f30446d6fc1..a46f534f82c 100644 --- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb +++ b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb @@ -4,7 +4,7 @@ "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\nsynergyh1\ntecan-spark\ntecan-infinite\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." + "source": "# Plate reading\n\nPyLabRobot supports the following plate readers:\n\n```{toctree}\n:maxdepth: 1\n\nbyonoy/absorbance\nbyonoy/luminescence\nbyonoy\ntecan-spark\ntecan-infinite\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." }, { "cell_type": "code", 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/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 index e1a63147be4..871509d9b21 100644 --- a/docs/user_guide/02_analytical/scales/scales.rst +++ b/docs/user_guide/02_analytical/scales/scales.rst @@ -130,5 +130,3 @@ parameter that controls how the scale handles measurement stability. .. toctree:: :maxdepth: 1 :hidden: - - mettler-toledo-WXS205SDU diff --git a/docs/user_guide/_getting-started/installation.md b/docs/user_guide/_getting-started/installation.md index 05d88c1ae58..bca89b7d671 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/agilent/biotek/cytation/hello-world.ipynb b/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb new file mode 100644 index 00000000000..b27bb85c150 --- /dev/null +++ b/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "wez9bxdabm", + "metadata": {}, + "source": [ + "# Agilent BioTek Cytation\n", + "\n", + "The Cytation is an Agilent BioTek multi-mode plate reader with optional microscopy imaging. Depending on the model it supports:\n", + "\n", + "- [Absorbance](../../../capabilities/absorbance)\n", + "- [Fluorescence](../../../capabilities/fluorescence)\n", + "- [Luminescence](../../../capabilities/luminescence)\n", + "- [Microscopy](../../../capabilities/microscopy)\n", + "- [Temperature control](../../../capabilities/temperature-control)\n", + "\n", + "| Model | PLR Name | Plate Reading | Microscopy | Temperature |\n", + "|---|---|---|---|---|\n", + "| Cytation 5 | `Cytation5` | Absorbance, Fluorescence, Luminescence | yes | yes |\n", + "| Cytation 1 | `Cytation1` | -- | yes | yes |\n", + "\n", + "Both models use `BioTekBackend` for serial communication and `CytationMicroscopyBackend` for imaging, communicating over FTDI USB. The Cytation 5 adds plate-reading capabilities on top of the shared microscopy and temperature-control features." + ] + }, + { + "cell_type": "markdown", + "id": "0rn94ubvq8dj", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "The examples below use a Cytation 5. For a Cytation 1, replace `Cytation5` with `Cytation1` (the Cytation 1 does not have `.absorbance`, `.fluorescence`, or `.luminescence` attributes)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ia4t5ga2ldg", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek import Cytation5\n", + "\n", + "c5 = Cytation5(name=\"cytation5\", device_id=\"20060813\")\n", + "await c5.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "35rmpdivj44", + "metadata": {}, + "source": [ + "Open and close the loading tray. Pass `BioTekLoadingTrayBackend.OpenParams(slow=True)` or `CloseParams(slow=True)` for slower motor travel if needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "l85qt1z6hdf", + "metadata": {}, + "outputs": [], + "source": [ + "await c5.loading_tray.open()\n", + "\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\", with_lid=True)\n", + "c5.loading_tray.assign_child_resource(plate)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76ae5e62", + "metadata": {}, + "outputs": [], + "source": [ + "await c5.loading_tray.close()" + ] + }, + { + "cell_type": "markdown", + "id": "hxkf2luxk9n", + "metadata": {}, + "source": [ + "## Plate reading (Cytation 5 only)\n", + "\n", + "The Cytation 5 exposes `.absorbance`, `.fluorescence`, and `.luminescence` capability objects. For the full API, see [Absorbance](../../../capabilities/absorbance), [Fluorescence](../../../capabilities/fluorescence), and [Luminescence](../../../capabilities/luminescence)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "hwipa2rwkzl", + "metadata": {}, + "outputs": [], + "source": [ + "# Absorbance\n", + "data = await c5.absorbance.read_absorbance(wavelength=450)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jmvn8du2t5", + "metadata": {}, + "outputs": [], + "source": [ + "# Fluorescence\n", + "data = await c5.fluorescence.read_fluorescence(\n", + " excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "oxn123gjoh", + "metadata": {}, + "outputs": [], + "source": [ + "# Luminescence\n", + "data = await c5.luminescence.read_luminescence(focal_height=4.5)" + ] + }, + { + "cell_type": "markdown", + "id": "1qn3t2pqvw", + "metadata": {}, + "source": [ + "## Microscopy\n", + "\n", + "Both the Cytation 5 and Cytation 1 expose a `.microscopy` capability. The Aravis camera is initialized during setup. For the full API, see [Microscopy](../../../capabilities/microscopy).\n", + "\n", + "Use {class}`~pylabrobot.agilent.biotek.plate_readers.cytation.microscopy_backend.CytationMicroscopyBackend.CaptureParams` to control LED intensity and coverage tiling." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "qr1jm6691a", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek import CytationMicroscopyBackend\n", + "from pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective\n", + "\n", + "res = await c5.microscopy.capture(\n", + " well=(1, 2),\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_4X_PL_FL_Phase,\n", + " focal_height=1.833,\n", + " exposure_time=5,\n", + " gain=8,\n", + " plate=plate,\n", + " backend_params=CytationMicroscopyBackend.CaptureParams(led_intensity=10),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "afb1bd4d", + "metadata": {}, + "outputs": [], + "source": [ + "from PIL import Image\n", + "Image.fromarray(res.images[0])" + ] + }, + { + "cell_type": "markdown", + "id": "km95iou30f", + "metadata": {}, + "source": [ + "Tile multiple fields of view with the `coverage` parameter:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fi1o94l1uni", + "metadata": {}, + "outputs": [], + "source": [ + "res = await c5.microscopy.capture(\n", + " well=(1, 2),\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_4X_PL_FL_Phase,\n", + " focal_height=2,\n", + " exposure_time=5,\n", + " gain=8,\n", + " plate=plate,\n", + " backend_params=CytationMicroscopyBackend.CaptureParams(\n", + " led_intensity=10,\n", + " coverage=(1, 1),\n", + " ),\n", + ")\n", + "print(f\"{len(res.images)} images captured\")\n", + "\n", + "from PIL import Image\n", + "Image.fromarray(res.images[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de5018a8", + "metadata": {}, + "outputs": [], + "source": [ + "# Autofocus\n", + "from pylabrobot.capabilities.microscopy import AutoFocus, evaluate_focus_nvmg_sobel\n", + "\n", + "res = await c5.microscopy.capture(\n", + " well=(1, 2),\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_4X_PL_FL_Phase,\n", + " focal_height=AutoFocus(\n", + " evaluate_focus=evaluate_focus_nvmg_sobel,\n", + " timeout=60,\n", + " low=1,\n", + " high=3,\n", + " ),\n", + " exposure_time=5,\n", + " gain=8,\n", + " plate=plate,\n", + " backend_params=CytationMicroscopyBackend.CaptureParams(led_intensity=10),\n", + ")\n", + "print(f\"best focal height: {res.focal_height:.3f} mm\")\n", + "\n", + "from PIL import Image\n", + "Image.fromarray(res.images[0])" + ] + }, + { + "cell_type": "markdown", + "id": "sa9pdeeo51", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Both models expose a `.temperature` controller. For the full API, see [Temperature Control](../../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "qhsjnerhl3", + "metadata": {}, + "outputs": [], + "source": [ + "await c5.temperature.set_temperature(37.0)\n", + "\n", + "current = await c5.temperature.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")\n", + "\n", + "await c5.temperature.deactivate()" + ] + }, + { + "cell_type": "markdown", + "id": "f667qnt4occ", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xcqz2zwu04g", + "metadata": {}, + "outputs": [], + "source": [ + "await c5.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.14.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/biotek/el406/hello-world.ipynb b/docs/user_guide/agilent/biotek/el406/hello-world.ipynb new file mode 100644 index 00000000000..a046f4e7375 --- /dev/null +++ b/docs/user_guide/agilent/biotek/el406/hello-world.ipynb @@ -0,0 +1,494 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "879joj9zw2q", + "metadata": {}, + "source": [ + "# BioTek EL406\n", + "\n", + "The BioTek EL406 plate washer has four subsystems — manifold, syringe pump, peristaltic pump, and shaker — each exposed as a capability on the `EL406` device." + ] + }, + { + "cell_type": "markdown", + "id": "hqb175huvvn", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mkf845m5cj", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from pylabrobot.agilent.biotek.el406 import EL406\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "os.environ[\"DYLD_LIBRARY_PATH\"] = \"/opt/homebrew/lib:\" + os.environ.get(\"DYLD_LIBRARY_PATH\", \"\")\n", + "el406 = EL406(name=\"el406\")\n", + "await el406.setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "el406.plate_holder.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "id": "sevahtcfwm", + "metadata": {}, + "source": [ + "## Manifold (plate washing)\n", + "\n", + "The wash manifold is the primary fluid system. A basic wash with default settings:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf7oo89jr7d", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.washer.wash(cycles=3, dispense_volume=300)" + ] + }, + { + "cell_type": "markdown", + "id": "2811958e", + "metadata": {}, + "source": [ + "Use {class}`~pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWasher96Backend.WashParams` to configure buffer, soak, and shake options:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42913ef6", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.el406.plate_washing_backend import EL406PlateWasher96Backend\n", + "\n", + "await el406.washer.wash(\n", + " cycles=3,\n", + " dispense_volume=300,\n", + " backend_params=EL406PlateWasher96Backend.WashParams(\n", + " buffer=\"A\",\n", + " soak_duration=10,\n", + " shake_duration=5,\n", + " shake_intensity=\"Medium\",\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8eac4bb9", + "metadata": {}, + "source": [ + "Aspirate and dispense:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5fd42dd3", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.washer.aspirate()\n", + "await el406.washer.dispense(volume=200)" + ] + }, + { + "cell_type": "markdown", + "id": "448b969f", + "metadata": {}, + "source": [ + "Prime the manifold lines using {class}`~pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWasher96Backend.PrimeParams`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43402ac1", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.washer.prime(\n", + " backend_params=EL406PlateWasher96Backend.PrimeParams(\n", + " volume=10000, buffer=\"A\",\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1y8kqv8xazr", + "metadata": {}, + "source": [ + "## Syringe pump\n", + "\n", + "Precise low-volume dispensing via dual syringe pumps (A/B). Prime the syringe lines first:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nee1ab1lhc", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.el406.syringe_dispensing_backend8 import EL406SyringeDispensingBackend8\n", + "\n", + "await el406.syringe_dispenser.prime(\n", + " volume=5000,\n", + " backend_params=EL406SyringeDispensingBackend8.PrimeParams(\n", + " syringe=\"A\"\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "eac825ed", + "metadata": {}, + "source": [ + "Dispense the same volume to all columns:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e22340a", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.syringe_dispenser.dispense(volumes=50)" + ] + }, + { + "cell_type": "markdown", + "id": "wo6glnrodrb", + "metadata": {}, + "source": [ + "Different volumes per column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ijfmjniqzzq", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.syringe_dispenser.dispense(volumes={\n", + " **{c: 100 for c in range(1, 7)},\n", + " **{c: 50 for c in range(7, 13)},\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "gky56c7k2h", + "metadata": {}, + "source": [ + "## Peristaltic pump\n", + "\n", + "Continuous-flow dispensing with cassette selection and row/column masking. Prime the lines first:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "drlarkoukr9", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend8 import EL406PeristalticDispensingBackend8\n", + "\n", + "await el406.peristaltic_dispenser.prime(\n", + " volume=300,\n", + " backend_params=EL406PeristalticDispensingBackend8.PrimeParams(\n", + " flow_rate=\"High\"\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "b1cf789b", + "metadata": {}, + "source": [ + "Dispense the same volume to all columns:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8fec238", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.peristaltic_dispenser.dispense(volumes=100)" + ] + }, + { + "cell_type": "markdown", + "id": "e29d47a8", + "metadata": {}, + "source": [ + "Different volumes per column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd6a8ed0", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.peristaltic_dispenser.dispense(volumes={\n", + " **{c: 200 for c in range(1, 7)},\n", + " **{c: 100 for c in range(7, 13)},\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "b2ee2828", + "metadata": {}, + "source": [ + "Purge the lines after dispensing:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22038ba5", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.peristaltic_dispenser.purge(\n", + " volume=300,\n", + " backend_params=EL406PeristalticDispensingBackend8.PrimeParams(\n", + " flow_rate=\"High\"\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "k1w9qy1vpti", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "The generic `Shaker.shake(speed, duration)` interface works — the EL406 reads the plate from the plate holder automatically. The `speed` parameter is ignored since the EL406 uses discrete intensity levels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nee1ab1lhc", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.shaker.shake(speed=0, duration=10)" + ] + }, + { + "cell_type": "markdown", + "id": "419c21de", + "metadata": {}, + "source": [ + "Use {class}`~pylabrobot.agilent.biotek.el406.shaking_backend.EL406ShakingBackend.ShakeParams` to control intensity and soak duration:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71a94e13", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.el406.shaking_backend import EL406ShakingBackend\n", + "\n", + "await el406.shaker.shake(\n", + " speed=0,\n", + " duration=10,\n", + " backend_params=EL406ShakingBackend.ShakeParams(\n", + " intensity=\"Fast\",\n", + " soak_duration=10,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "466fcc8b", + "metadata": {}, + "source": [ + "## Advanced usage\n", + "\n", + "### Manual batching\n", + "\n", + "Each step command (wash, dispense, prime, etc.) automatically starts and stops a **batch** — the device's \"ready to execute\" mode. When you call multiple commands in sequence, each one opens and closes its own batch, which adds overhead.\n", + "\n", + "To avoid this, wrap multiple commands in a single `driver.batch()` context manager. The inner commands detect they are already inside a batch and skip the redundant start/cleanup:\n", + "\n", + "```{note}\n", + "When dispensing different volumes per column (e.g. `volumes={1: 100, 7: 50}`), the `dispense()` method handles batching internally — it groups columns by volume and sends one command per group, all within a single batch. You only need manual batching when combining **different operations** (e.g. prime + dispense + shake).\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76df5c84", + "metadata": {}, + "outputs": [], + "source": [ + "async with el406.driver.batch():\n", + " await el406.peristaltic_dispenser.prime(\n", + " volume=300,\n", + " backend_params=EL406PeristalticDispensingBackend8.PrimeParams(flow_rate=\"High\"),\n", + " )\n", + " await el406.peristaltic_dispenser.dispense(volumes=100)\n", + " await el406.shaker.shake(speed=0, duration=5)" + ] + }, + { + "cell_type": "markdown", + "id": "b79b9e79", + "metadata": {}, + "source": [ + "### Combining subsystems in a single batch\n", + "\n", + "A full protocol often chains operations across subsystems. Manual batching keeps the device in its ready state throughout:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8982034e", + "metadata": {}, + "outputs": [], + "source": [ + "async with el406.driver.batch():\n", + " # Step 1: wash the plate\n", + " await el406.washer.wash(cycles=3, dispense_volume=300)\n", + "\n", + " # Step 2: dispense reagent via syringe\n", + " await el406.syringe_dispenser.dispense(volumes=25)\n", + "\n", + " # Step 3: shake to mix\n", + " await el406.shaker.shake(\n", + " speed=0,\n", + " duration=30,\n", + " backend_params=EL406ShakingBackend.ShakeParams(intensity=\"Medium\"),\n", + " )\n", + "\n", + " # Step 4: final aspirate\n", + " await el406.washer.aspirate()" + ] + }, + { + "cell_type": "markdown", + "id": "fd8ff57e", + "metadata": {}, + "source": [ + "### Column and row masking\n", + "\n", + "Target specific wells using column dicts and the `rows` parameter. For 384-well plates, rows are grouped into 2 quadrants; for 1536-well, into 4:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00390c96", + "metadata": {}, + "outputs": [], + "source": [ + "# Dispense 200 uL to columns 1-6 only\n", + "await el406.peristaltic_dispenser.dispense(\n", + " volumes={c: 200 for c in range(1, 7)},\n", + ")\n", + "\n", + "# On a 384-well plate, dispense to row quadrant 1 only\n", + "await el406.peristaltic_dispenser.dispense(\n", + " volumes=100,\n", + " backend_params=EL406PeristalticDispensingBackend8.DispenseParams(\n", + " rows=[1],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "q0uusttfg8", + "metadata": {}, + "source": [ + "## Device-level operations" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "p8372uxod3m", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.el406.enums import EL406WasherManifold\n", + "\n", + "await el406.driver.set_washer_manifold(EL406WasherManifold.TUBE_96_DUAL)\n", + "await el406.driver.reset()" + ] + }, + { + "cell_type": "markdown", + "id": "v7l85tg1hc", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9i2934a6xgi", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.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.14.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/biotek/index.md b/docs/user_guide/agilent/biotek/index.md new file mode 100644 index 00000000000..f3a09e263c7 --- /dev/null +++ b/docs/user_guide/agilent/biotek/index.md @@ -0,0 +1,9 @@ +# BioTek + +```{toctree} +:maxdepth: 1 + +el406/hello-world +cytation/hello-world +synergy_h1/hello-world +``` diff --git a/docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb b/docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb new file mode 100644 index 00000000000..1c7f46820ad --- /dev/null +++ b/docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb @@ -0,0 +1,179 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9hb8l612ogw", + "source": "# Agilent BioTek Synergy H1\n\nThe Synergy H1 is a multimode microplate reader from Agilent BioTek. It supports:\n\n- [Absorbance](../../../capabilities/absorbance) (230--999 nm)\n- [Fluorescence](../../../capabilities/fluorescence) (excitation 250--700 nm, emission 250--700 nm)\n- [Luminescence](../../../capabilities/luminescence)\n- [Temperature control](../../../capabilities/temperature-control) (heating only, up to 45 °C)\n\nIt communicates over USB via an FTDI serial interface.\n\n```bash\npip install pylabrobot[ftdi]\n```\n\nSee the [Synergy H1 user manual](https://cqls.oregonstate.edu/sites/cqls.oregonstate.edu/files/synergy_h1_user_manual_sd-xb000426.pdf) for hardware setup.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "df745ilqnca", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "vztlq0m8g5", + "source": "from pylabrobot.agilent.biotek import SynergyH1\n\nreader = SynergyH1(name=\"reader\")\nawait reader.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qpm6e4d70h", + "source": "If you have multiple FTDI devices connected, pass a `device_id` to select the correct one:\n\n```python\nreader = SynergyH1(name=\"reader\", device_id=\"12345678\")\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "oiqom4e4bda", + "source": "Open the tray to load a plate, then close it. Assign a plate resource so the reader knows the well layout.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6c6ji331z5e", + "source": "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\nawait reader.open()\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nreader.plate_holder.assign_child_resource(plate)\nawait reader.close()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ac212h02m47", + "source": "## Absorbance\n\nThe Synergy H1 supports absorbance readings from 230 to 999 nm. For the full API, see [Absorbance](../../../capabilities/absorbance).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "p6bs81vwg5j", + "source": "results = await reader.absorbance.read(plate, wavelength=434)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qkpmhl42ovn", + "source": "## Fluorescence\n\nExcitation range: 250--700 nm. Emission range: 250--700 nm. Focal height range: 4.5--10.68 mm. For the full API, see [Fluorescence](../../../capabilities/fluorescence).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "0qsn8g9uki2i", + "source": "results = await reader.fluorescence.read(\n plate, excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "h55fu4bcvyl", + "source": "## Luminescence\n\nFocal height range: 4.5--10.68 mm. For the full API, see [Luminescence](../../../capabilities/luminescence).\n\nUse {class}`~pylabrobot.agilent.biotek.plate_readers.base.BioTekBackend.LuminescenceParams` to set the integration time (default 1 s).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "fzj3e9tf9ui", + "source": "results = await reader.luminescence.read(plate, focal_height=4.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "643yoi3vyz8", + "source": "With a custom integration time:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "eened8jpbh", + "source": "from pylabrobot.agilent.biotek import BioTekBackend\n\nresults = await reader.luminescence.read(\n plate,\n focal_height=4.5,\n backend_params=BioTekBackend.LuminescenceParams(integration_time=2),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "vwzselcn4bn", + "source": "## Shaking\n\nThe Synergy H1 supports linear and orbital shaking via the driver. Frequency is specified in mm (1--6 mm, where lower values correspond to higher CPM).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "33sl477jl52", + "source": "await reader.driver.shake(\n shake_type=BioTekBackend.ShakeType.LINEAR,\n frequency=4, # linear frequency in mm, 1 <= frequency <= 6\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "h3aajxmrwhi", + "source": "await reader.driver.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "efp70zyvdrs", + "source": "## Temperature control\n\nThe Synergy H1 supports heating up to 45 °C but does not support active cooling. For the full API, see [Temperature Control](../../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "g2ku2uh2zud", + "source": "await reader.temperature.set_temperature(37.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "l9hi5407ljb", + "source": "current = await reader.temperature.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "0p22gay4kx5", + "source": "await reader.temperature.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ypd7hm8dc8g", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1slsxpowz65", + "source": "await reader.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md new file mode 100644 index 00000000000..1b5d31e58fb --- /dev/null +++ b/docs/user_guide/agilent/index.md @@ -0,0 +1,8 @@ +# Agilent + +```{toctree} +:maxdepth: 1 + +biotek/index +vspin/hello-world +``` diff --git a/docs/user_guide/agilent/vspin/hello-world.ipynb b/docs/user_guide/agilent/vspin/hello-world.ipynb new file mode 100644 index 00000000000..ca1ded525dc --- /dev/null +++ b/docs/user_guide/agilent/vspin/hello-world.ipynb @@ -0,0 +1,185 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bw6q5a50817", + "source": "# Agilent VSpin\n\nThe VSpin is a compact centrifuge from Agilent. It supports:\n\n- [Centrifuging](../../capabilities/centrifuging) (1--1000 x g, configurable acceleration/deceleration)\n- Bucket calibration and positioning\n- Door and bucket lock control\n\nIt can optionally be paired with an **Access2** loader for automated plate loading/unloading.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "j8n97l6kd1f", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "pwf1na22y6c", + "source": "from pylabrobot.agilent.vspin import VSpin\n\nvspin = VSpin(name=\"vspin\", device_id=\"YOUR_FTDI_ID_HERE\")\nawait vspin.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "vuzw3bqy8pk", + "source": "Find your FTDI device ID with:\n\n```bash\npython -m pylibftdi.examples.list_devices\n```\n\nSee [Installation](#installation) below for driver setup instructions.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "3vs9t38mrs3", + "source": "## Centrifuging\n\nThe VSpin exposes a {class}`~pylabrobot.capabilities.centrifuging.centrifuging.Centrifuge` on `vspin.centrifuging`. For the full API, see [Centrifuging](../../capabilities/centrifuging).\n\n### Bucket positioning\n\nBefore using the go-to-bucket commands, you must calibrate the bucket positions (see [Calibrating bucket 1 position](#calibrating-bucket-1-position)).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7vq8uj0hrb", + "source": "await vspin.centrifuging.go_to_bucket1()\nawait vspin.centrifuging.open_door()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "x6eq1hunn5n", + "source": "await vspin.centrifuging.go_to_bucket2()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "2eqe6c0cc6p", + "source": "### Spinning\n\nUse {class}`~pylabrobot.agilent.vspin.VSpinCentrifugeBackend.SpinParams` to configure acceleration and deceleration (0--1 scale).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ghmxw8tnh3m", + "source": "from pylabrobot.agilent.vspin import VSpinCentrifugeBackend\n\nawait vspin.centrifuging.spin(\n g=800,\n duration=60,\n backend_params=VSpinCentrifugeBackend.SpinParams(\n acceleration=1.0,\n deceleration=1.0,\n ),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "viflffmduz", + "source": "### Door and bucket control", + "metadata": {} + }, + { + "cell_type": "code", + "id": "9mluq2uvrf", + "source": "await vspin.centrifuging.open_door()\nawait vspin.centrifuging.close_door()\nawait vspin.centrifuging.lock_door()\nawait vspin.centrifuging.unlock_door()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "7wd26nv1l3q", + "source": "await vspin.centrifuging.lock_bucket()\nawait vspin.centrifuging.unlock_bucket()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "phdlm6tq4vm", + "source": "### Status queries\n\nThe driver exposes low-level status queries:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "9w6vi7xpcp", + "source": "await vspin.driver.request_door_locked()\nawait vspin.driver.request_door_open()\nawait vspin.driver.request_bucket_locked()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "2m1caz8kbbj", + "source": "## Calibrating bucket 1 position\n\nYou need to calibrate the bucket 1 position once per VSpin. The calibration is saved to disk at `~/.pylabrobot/vspin_bucket_calibrations.json` (keyed by USB serial number).\n\nThere are two ways to position bucket 1:\n\n### Using code\n\nUse `go_to_position` to rotate the buckets. A full rotation is 8000 ticks (4.5 degrees per 100 ticks).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xpykzwzil7c", + "source": "await vspin.centrifuging.backend.go_to_position(100)\nawait vspin.centrifuging.backend.set_bucket_1_position_to_current()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "sg17ztay9yj", + "source": "### Manual rotation\n\nYou can open the door, unlock the bucket, and manually rotate the buckets to align bucket 1 with the door.\n\n```{warning}\nThe VSpin has a safety mechanism that closes the door when it detects movement. This triggers when you rotate the buckets manually too fast. Be careful -- it will save time compared to using code, but the door can close on your fingers.\n```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7a81a1dft1", + "source": "await vspin.centrifuging.open_door()\nawait vspin.centrifuging.unlock_bucket()\n# Manually rotate buckets to align bucket 1 with door\nawait vspin.centrifuging.backend.set_bucket_1_position_to_current()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "l6m217gt0br", + "source": "## Access2 loader\n\nThe VSpin can optionally be paired with an Access2 loader for automated plate loading/unloading. The loader is optional -- you can also use a robotic arm (e.g. iSWAP) to move plates directly into the centrifuge.\n\nWhen using the loader, specify the FTDI device IDs for both the VSpin and the loader, since both use FTDI and are otherwise indistinguishable.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ca2kxhh8w6", + "source": "import asyncio\n\nfrom pylabrobot.agilent.vspin import VSpin, Access2\n\nvspin = VSpin(name=\"vspin\", device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\nloader = Access2(name=\"loader\", device_id=\"YOUR_LOADER_FTDI_ID_HERE\", vspin=vspin)\n\nawait asyncio.gather(vspin.setup(), loader.setup())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "olgd4ncjaa", + "source": "# Go to a bucket and open the door before loading\nawait vspin.centrifuging.go_to_bucket1()\nawait vspin.centrifuging.open_door()\n\n# Assign a plate to the loader (can also be done implicitly, e.g. lh.move_plate(plate, loader))\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nloader.assign_child_resource(plate)\n\n# Load and unload\nawait loader.load()\nawait loader.unload()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "1n5svyh62zt", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "tnk2az2xn", + "source": "await vspin.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "hw82l6sqoek", + "source": "## Installation\n\nThe VSpin connects via FTDI USB. You need the `libftdi` library installed on your system.\n\n### macOS\n\n```bash\nbrew install libftdi\n```\n\n### Linux (Debian/Ubuntu)\n\n```bash\nsudo apt-get install libftdi-dev\n```\n\n### Windows\n\n1. Locate your Python `Scripts` folder (run `python -c \"import sys; print(sys.executable)\"` to find your Python directory).\n2. Download the [FTDI Development Kit](https://sourceforge.net/projects/picusb/files/libftdi1-1.5_devkit_x86_x64_19July2020.zip/download).\n3. Copy `libftdi1.dll` and `libusb-1.0.dll` from the `bin64` folder into your Python `Scripts` folder.\n4. Use [Zadig](https://zadig.akeo.ie/) to replace the VSpin's default driver with `libusbk`. To identify the VSpin device, disconnect its RS232 cable while monitoring the Zadig device list -- the device that disappears is the VSpin.\n\n```{note}\nTo revert to the original driver (e.g. for the Agilent Centrifuge Config Tool), uninstall the `libusbk` driver in Device Manager. The default driver will reinstall automatically.\n```\n\n### Finding the FTDI ID\n\n```bash\npython -m pylibftdi.examples.list_devices\n```\n\nThis outputs something like `FTDI:USB Serial Converter:FTE0RJ5T`. The last field is the device ID.", + "metadata": {} + } + ], + "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/azenta/a4s/hello-world.ipynb b/docs/user_guide/azenta/a4s/hello-world.ipynb new file mode 100644 index 00000000000..9cfbb6de402 --- /dev/null +++ b/docs/user_guide/azenta/a4s/hello-world.ipynb @@ -0,0 +1,135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f3ht9hmqmtb", + "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 two programmatically-accessible action parameters for sealing: temperature and sealing duration. | ![a4s](img/azenta_a4s.png) |", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "toywe80qo6l", + "source": "## Setup\n\nThe a4S exposes a [Sealer](../../capabilities/sealing) and a [Temperature Controller](../../capabilities/temperature-control).\n\nIdentify the serial port on your control PC and create an `A4S` instance:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "taszk7zqg4f", + "source": "from pylabrobot.azenta import A4S\n\ns = A4S(name=\"a4s\", port=\"/dev/tty.usbserial-0001\")\nawait s.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "jfzh8gnavi", + "source": "```{note}\nWhen the a4S is first powered on, it will open its loading tray -- the **machine default state is open**.\n\nIf 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```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "fcc7k7g481u", + "source": "## Sealing\n\nThe a4S exposes a {class}`~pylabrobot.capabilities.sealing.sealing.Sealer` on `s.sealer`. For the full API, see [Sealing](../../capabilities/sealing).\n\nSeal a plate with a single command:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "mc8bowt5ph", + "source": "await s.sealer.seal(\n temperature=180, # degrees Celsius\n duration=5, # seconds\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "6zqpnsgzwjl", + "source": "This command will:\n\n1. Set the temperature\n2. Wait until the temperature is reached\n3. Move the plate into the machine / close the loading tray\n4. Cut the film off its roll\n5. Seal the film onto the plate for the specified duration\n6. Move the plate out of the machine / open the loading tray\n\nYou can also open and close the loading tray independently:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "dihxxyypl27", + "source": "await s.sealer.close()\nawait s.sealer.open()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "exik1ymzpec", + "source": "```{warning}\nClosing the loading tray also **cuts the film/foil** without performing a seal. A single leaf of film will fall onto the tray (or onto a plate if one is present). Opening afterwards will require manual removal of that leaf.\n\nThis is a mechanical safety feature of the a4S design -- without it the film could buckle and stick to hot internals.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "kk2eozbe1y", + "source": "## Temperature control\n\nThe a4S exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `s.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).\n\nPre-set the temperature to accelerate subsequent sealing steps:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "zfgs53tvjnl", + "source": "await s.tc.set_temperature(170)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "dtpbheb2x2b", + "source": "current = await s.tc.request_current_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "31sbr45l31n", + "source": "await s.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ttf1c07n5pc", + "source": "## Querying machine status\n\nThe a4S driver exposes detailed status information including sensor states:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "qv9xuidyncq", + "source": "status = await s.driver.request_status()\nprint(\"current_temperature: \", status.current_temperature)\nprint(\"system_status: \", status.system_status)\nprint(\"heater_block_status: \", status.heater_block_status)\nprint(\"error_code: \", status.error_code)\nprint(\"warning_code: \", status.warning_code)\nprint(\"sensor_status:\")\nprint(\" shuttle_middle_sensor: \", status.sensor_status.shuttle_middle_sensor)\nprint(\" shuttle_open_sensor: \", status.sensor_status.shuttle_open_sensor)\nprint(\" shuttle_close_sensor: \", status.sensor_status.shuttle_close_sensor)\nprint(\" clean_door_sensor: \", status.sensor_status.clean_door_sensor)\nprint(\" seal_roll_sensor: \", status.sensor_status.seal_roll_sensor)\nprint(\" heater_motor_up_sensor: \", status.sensor_status.heater_motor_up_sensor)\nprint(\" heater_motor_down_sensor: \", status.sensor_status.heater_motor_down_sensor)\nprint(\"remaining_time: \", status.remaining_time)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "u3d9sho4ajn", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "9y7upc8miwj", + "source": "await s.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/azenta/a4s/img/azenta_a4s.png b/docs/user_guide/azenta/a4s/img/azenta_a4s.png new file mode 100644 index 00000000000..30bf0e0dba8 Binary files /dev/null and b/docs/user_guide/azenta/a4s/img/azenta_a4s.png differ diff --git a/docs/user_guide/azenta/index.md b/docs/user_guide/azenta/index.md new file mode 100644 index 00000000000..72f87300a09 --- /dev/null +++ b/docs/user_guide/azenta/index.md @@ -0,0 +1,8 @@ +# Azenta + +```{toctree} +:maxdepth: 1 + +a4s/hello-world +xpeel/hello-world +``` diff --git a/docs/user_guide/azenta/xpeel/hello-world.ipynb b/docs/user_guide/azenta/xpeel/hello-world.ipynb new file mode 100644 index 00000000000..4ef6653b89c --- /dev/null +++ b/docs/user_guide/azenta/xpeel/hello-world.ipynb @@ -0,0 +1,85 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b50py33bxta", + "source": "# Azenta XPeel\n\nThe XPeel is an automated plate seal remover from Azenta. It supports:\n\n- [Peeling](../../capabilities/peeling) (automated de-seal cycles with configurable speed and adhesion time)\n\nThe device communicates over RS-232 serial. Connect via a serial or USB-to-serial adapter.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ppn037up42f", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ja1g5fbfom", + "source": "from pylabrobot.azenta import XPeel\n\nxpeel = XPeel(name=\"xpeel\", port=\"/dev/ttyUSB0\") # replace with your port\nawait xpeel.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "oifhl63dbm", + "source": "## Peeling\n\nThe XPeel exposes a {class}`~pylabrobot.capabilities.peeling.peeling.Peeler` on `xpeel.peeler`. For the full API, see [Peeling](../../capabilities/peeling).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "o4yxs7wjvhr", + "source": "await xpeel.peeler.peel()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "exyxifvntk8", + "source": "The peel cycle can be customized with {class}`~pylabrobot.azenta.xpeel.XPeelPeelerBackend.PeelParams`:\n\n- `begin_location`: starting position offset in mm (-2, 0, 2, or 4)\n- `fast`: use faster peel speed\n- `adhere_time`: tape adhesion time in seconds (2.5, 5.0, 7.5, or 10.0)", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kli2o1ienr", + "source": "from pylabrobot.azenta import XPeelPeelerBackend\n\nawait xpeel.peeler.peel(\n backend_params=XPeelPeelerBackend.PeelParams(\n begin_location=2,\n fast=True,\n adhere_time=5.0,\n )\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ha8at6dybo", + "source": "## Driver utilities\n\nThe underlying {class}`~pylabrobot.azenta.xpeel.XPeelDriver` is available on `xpeel.driver` and exposes additional hardware commands.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "gqmcwlszslj", + "source": "Check for a seal on the plate:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "97wrtr03eq", + "source": "result = await xpeel.driver.seal_check()\nprint(result) # \"seal_detected\", \"no_seal\", or \"plate_not_detected\"", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb b/docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb new file mode 100644 index 00000000000..163fa91e248 --- /dev/null +++ b/docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb @@ -0,0 +1,204 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# BMG Labtech CLARIOstar\n", + "\n", + "The CLARIOstar is a multi-mode plate reader from BMG Labtech. It supports:\n", + "\n", + "- [Absorbance](../../../capabilities/absorbance) (OD or transmittance)\n", + "- [Luminescence](../../../capabilities/luminescence)\n", + "- [Fluorescence](../../../capabilities/fluorescence) (not yet implemented in PLR)\n", + "\n", + "Additional hardware features include a motorized loading tray, temperature control, and shaking.\n", + "\n", + "| Model | PLR Name |\n", + "|---|---|\n", + "| CLARIOstar / CLARIOstar Plus | `CLARIOstar` |\n", + "\n", + "- [OEM Link](https://www.bmglabtech.com/en/clariostar-plus/)\n", + "- **Communication**: USB (FTDI, VID 0x0403 / PID 0xBB68) at 125 000 baud\n", + "- **Communication level**: Firmware" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "The CLARIOstar requires two cable connections:\n", + "\n", + "1. Power cord (standard IEC C13)\n", + "2. USB cable (USB-B with security screws at the CLARIOstar end; USB-A at the control PC end)\n", + "\n", + "If you have a plate stacking unit, an additional RS-232 port is available on the CLARIOstar." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.bmg_labtech import CLARIOstar\n", + "\n", + "reader = CLARIOstar(name=\"clariostar\")\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{note}\n", + "On `setup()` the device performs its initialization routine. Pass a `device_id` to `CLARIOstar()` if you have multiple FTDI devices connected.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading tray\n", + "\n", + "Open and close the motorized plate tray:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.open()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# load a plate (manually or via robotic arm)\n", + "await reader.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "The absorbance capability is exposed as `reader.absorbance`. For the full API, see [Absorbance](../../../capabilities/absorbance).\n", + "\n", + "Use {class}`~pylabrobot.bmg_labtech.clariostar.absorbance_backend.CLARIOstarAbsorbanceParams` to select between OD and transmittance output." + ] + }, + { + "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.plate_holder.assign_child_resource(plate)\n", + "\n", + "results = await reader.absorbance.read_absorbance(plate, wavelength=450)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To get raw transmittance values instead of OD:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.bmg_labtech import CLARIOstarAbsorbanceParams\n", + "\n", + "results = await reader.absorbance.read_absorbance(\n", + " plate,\n", + " wavelength=450,\n", + " backend_params=CLARIOstarAbsorbanceParams(report=\"transmittance\"),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Luminescence\n", + "\n", + "The luminescence capability is exposed as `reader.luminescence`. For the full API, see [Luminescence](../../../capabilities/luminescence)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.luminescence.read_luminescence(plate, focal_height=13.0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fluorescence\n", + "\n", + "The fluorescence capability is exposed as `reader.fluorescence`. For the full API, see [Fluorescence](../../../capabilities/fluorescence).\n", + "\n", + "```{note}\n", + "Fluorescence reading is not yet implemented in the CLARIOstar driver.\n", + "```" + ] + }, + { + "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/bmg_labtech/index.md b/docs/user_guide/bmg_labtech/index.md new file mode 100644 index 00000000000..c7de9147e4b --- /dev/null +++ b/docs/user_guide/bmg_labtech/index.md @@ -0,0 +1,7 @@ +# BMG Labtech + +```{toctree} +:maxdepth: 1 + +clariostar/hello-world +``` 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..236ba7be744 --- /dev/null +++ b/docs/user_guide/brooks/precise_flex/hello-world.ipynb @@ -0,0 +1,394 @@ +{ + "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. They support:\n", + "\n", + "- [Arms](../../capabilities/arms) (pick/place, Cartesian and joint movement, freedrive teaching)\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) |" + ] + }, + { + "cell_type": "markdown", + "id": "zkxzri4ogld", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "7cd93d15", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "klqi0r4257k", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-20 21:20:11,081 - pylabrobot.brooks.precise_flex - INFO - [PreciseFlex 10.0.0.1] connected: port=10100\n" + ] + } + ], + "source": [ + "from pylabrobot.brooks import PreciseFlex400\n", + "\n", + "pf = PreciseFlex400(host=\"10.0.0.1\", port=10100, has_rail=True)\n", + "await pf.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "76a4mb347b6", + "metadata": {}, + "source": [ + "By default, `setup()` powers on the robot, attaches it, and homes it. Pass `skip_home=True` to the driver to skip homing." + ] + }, + { + "cell_type": "markdown", + "id": "hmybpz3v5bk", + "metadata": {}, + "source": [ + "## Arm capabilities\n", + "\n", + "The PreciseFlex exposes an {class}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm` on `pf.arm`. For the full arm API, see [Arms](../../capabilities/arms)." + ] + }, + { + "cell_type": "markdown", + "id": "ykbt74aafkl", + "metadata": {}, + "source": [ + "### Gripper control" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "trbcgxwrh6k", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-20 21:20:11,097 - pylabrobot.brooks.precise_flex - INFO - [PreciseFlex 10.0.0.1] open_gripper: width_mm=120\n", + "2026-04-20 21:20:11,233 - pylabrobot.brooks.precise_flex - INFO - [PreciseFlex 10.0.0.1] close_gripper: width_mm=80\n" + ] + }, + { + "data": { + "text/plain": [ + "False" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await pf.arm.open_gripper(gripper_width=120)\n", + "await pf.arm.close_gripper(gripper_width=80)\n", + "await pf.arm.backend.is_gripper_closed()" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "75f65b4a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PreciseFlexGripperLocation(location=Coordinate(x=262.0253, y=0.0042, z=1.501), rotation=Rotation(x=-180, y=90, z=0.0010000000000864489), rail=0.015, orientation='left', wrist='ccw')\n", + "{: 0.015, : 1.501, : 72.977, : 199.323, : -272.299, : 79.8}\n" + ] + } + ], + "source": [ + "c = await pf.arm.backend.request_gripper_location()\n", + "j = await pf.arm.backend.request_joint_position()\n", + "print(c)\n", + "print(j)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "02775cee", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{1: 1.501,\n", + " 2: 72.97699018589508,\n", + " 3: -160.67699386837506,\n", + " 4: 87.70100368248008,\n", + " 6: 0.015}" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pylabrobot.brooks.kinematics import fk, ik,PF400Params\n", + "ik(c, PF400Params())" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "99da4e15", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "PreciseFlexGripperLocation(location=Coordinate(x=262.0253, y=0.0042, z=1.501), rotation=Rotation(x=-180, y=90, z=0.0010000000000864489), rail=0.015, orientation='right', wrist='ccw')" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pylabrobot.brooks.kinematics import fk, ik,PF400Params\n", + "fk(j, PF400Params())" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "65639682", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "({1: 1.501,\n", + " 2: 72.97699018589508,\n", + " 3: -160.67699386837506,\n", + " 4: -272.29899631751994,\n", + " 6: 0.015},\n", + " {: 0.015,\n", + " : 1.501,\n", + " : 72.977,\n", + " : 199.323,\n", + " : -272.299,\n", + " : 79.8})" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ik(fk(j, PF400Params()), PF400Params()), j" + ] + }, + { + "cell_type": "markdown", + "id": "wczvow78is", + "metadata": {}, + "source": [ + "### Cartesian movement\n", + "\n", + "Move the arm to a Cartesian location using {class}`~pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.MoveToLocationParams` to set speed and elbow orientation:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4pt8miz37fv", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "7c21rjo3cwl", + "metadata": {}, + "source": [ + "### Joint movement\n", + "\n", + "Move the arm using joint coordinates with {class}`~pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.MoveToJointPositionParams`:\n", + "\n", + "```{warning}\n", + "Moving to arbitrary joint positions can cause the arm to collide with its base or nearby equipment. Verify coordinates carefully.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d35e4ed4", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "sxfy9raob0p", + "metadata": {}, + "source": [ + "### Querying position\n", + "\n", + "Get the current joint angles or Cartesian end-effector position:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jur9gk3qeu", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "310j6lswzkk", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "tlleekbqklc", + "metadata": {}, + "source": [ + "### Plate pick and place\n", + "\n", + "Pick up and place plates using Cartesian coordinates. Use {class}`~pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.PickUpParams` to configure the access pattern, grasp force, and elbow orientation, and {class}`~pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.DropParams` for placement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nixhsn73oh", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "nq6vk6a7xue", + "metadata": {}, + "source": [ + "### Freedrive (teaching mode)\n", + "\n", + "Enter freedrive mode to manually position the arm, then read coordinates for use in your protocol:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "h5w7fof097u", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "3y5h6b8rfwu", + "metadata": {}, + "source": [ + "### Miscellaneous commands\n", + "\n", + "Home the arm, move to the safe position, or halt all motion:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1kf8nymr64n", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "bxd33iqpeo4", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "hxz2e52yxru", + "metadata": {}, + "outputs": [], + "source": [ + "await pf.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.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..b1255e4f78b --- /dev/null +++ b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb @@ -0,0 +1,105 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "n0a5pl74u0o", + "source": "# Byonoy Absorbance 96\n\nThe Absorbance 96 Automate (A96A) is a USB-HID plate reader from Byonoy that measures absorbance across a 96-well plate in a single flash. It supports:\n\n- [Absorbance](../../capabilities/absorbance) (single-wavelength, full-plate)\n\nThe 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| Model | PLR Name | Factory function |\n|---|---|---|\n| Absorbance 96 Automate (full setup) | `ByonoyAbsorbance96` | `byonoy_a96a` |\n| Detection unit only | `ByonoyAbsorbance96` | `byonoy_a96a_detection_unit` |\n| Illumination unit | `Resource` | `byonoy_a96a_illumination_unit` |\n| Parking base (no backend) | `ByonoyAbsorbanceBaseUnit` | `byonoy_a96a_parking_unit` |\n| SBS adapter | `ResourceHolder` | `byonoy_sbs_adapter` |\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x1199`)\n- **Communication level**: Firmware", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "tupar6h068", + "source": "## Setup\n\nUse `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).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "w1m3gjaser", + "source": "from pylabrobot.byonoy import byonoy_a96a\n\nreader, illumination_unit = byonoy_a96a(name=\"a96a\")\nawait reader.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "tby71rvde8s", + "source": "During `setup()`, the backend opens the USB HID connection and runs an initialization measurement to calibrate the sensor.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "j2dquxyo5gm", + "source": "## Absorbance\n\nThe absorbance capability is exposed as `reader.absorbance`. For the full API, see [Absorbance](../../capabilities/absorbance).\n\nBefore 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).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "dugdvssa3zq", + "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\nreader.illumination_unit_holder.unassign_child_resource(illumination_unit)\n\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nreader.plate_holder.assign_child_resource(plate)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "re14dqp1uxb", + "source": "Read absorbance at a single wavelength. The wavelength must be one of the device's available wavelengths (queried automatically during `setup()`).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bhi338chcfk", + "source": "results = await reader.absorbance.read_absorbance(plate, wavelength=450)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qrtoibefkpl", + "source": "You can check which wavelengths are available on your unit:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6p6cl01jswv", + "source": "print(reader.driver.available_wavelengths)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "43bn89mateq", + "source": "## Resource layout\n\nThe 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```\nByonoyAbsorbance96 (base + device)\n +-- plate_holder (assign plates here)\n +-- illumination_unit_holder (illumination unit sits here during measurement)\n```\n\nA separate `byonoy_a96a_parking_unit` can be used as a second base (no backend) where the illumination unit rests while plates are swapped.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "vt3457fkbz", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "2bhu26jg5gt", + "source": "await reader.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/byonoy/index.md b/docs/user_guide/byonoy/index.md new file mode 100644 index 00000000000..3cfedb983f5 --- /dev/null +++ b/docs/user_guide/byonoy/index.md @@ -0,0 +1,8 @@ +# Byonoy + +```{toctree} +:maxdepth: 1 + +absorbance_96/hello-world +luminescence_96/hello-world +``` 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..d225b5e7c5d --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ey04kywsg19", + "source": "# Byonoy Luminescence 96\n\nThe Luminescence 96 is a USB-HID plate reader from Byonoy that measures luminescence across a 96-well plate. It supports:\n\n- [Luminescence](../../capabilities/luminescence) (full-plate, configurable integration time)\n\nThe hardware consists of a **base unit** (holds the plate) and a **reader unit** (detector, sits on top during measurement). PLR models both as resources so a robotic arm can move the reader unit on and off the base. Two hardware variants exist: the L96 (manual) and L96A (automate, with a preferred pickup location for robotic handling).\n\n| Model | PLR Name | Factory function |\n|---|---|---|\n| L96 full setup | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96` |\n| L96A full setup (automate) | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96a` |\n| L96 reader unit only | `ByonoyLuminescence96` | `byonoy_l96_reader_unit` |\n| L96A reader unit only | `ByonoyLuminescence96` | `byonoy_l96a_reader_unit` |\n| L96 base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96_base_unit` |\n| L96A base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96a_base_unit` |\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x119B`)\n- **Communication level**: Firmware", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "9y33vci34d6", + "source": "## Setup\n\nUse `byonoy_l96a` (automate) or `byonoy_l96` (manual) to create the full setup (base unit + reader unit). The reader unit is both a `Resource` and a `Device`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "g7yhpou4ecd", + "source": "from pylabrobot.byonoy import byonoy_l96a\n\nbase, reader = byonoy_l96a(name=\"l96a\")\nawait reader.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "yaf5kv2g5np", + "source": "## Luminescence\n\nThe luminescence capability is exposed as `reader.luminescence`. For the full API, see [Luminescence](../../capabilities/luminescence).\n\nBefore reading, remove the reader unit from the base so a plate can be assigned, then place the reader unit back.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ochf7cbgdxi", + "source": "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Remove reader unit so plate can be loaded\nbase.reader_unit_holder.unassign_child_resource(reader)\n\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nbase.plate_holder.assign_child_resource(plate)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "h7wn22dkjls", + "source": "results = await reader.luminescence.read_luminescence(plate, focal_height=13.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "3zuj9ae45as", + "source": "### Custom integration time\n\nUse {class}`~pylabrobot.byonoy.luminescence_96.ByonoyLuminescence96Backend.LuminescenceParams` to set the integration time (in seconds, default 2).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "49joyxhhy8t", + "source": "from pylabrobot.byonoy import ByonoyLuminescence96Backend\n\nresults = await reader.luminescence.read_luminescence(\n plate,\n focal_height=13.0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(integration_time=5),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "tjfltsit53", + "source": "## Resource layout\n\nThe Luminescence 96 has an interlock: you cannot assign a plate to the base while the reader unit is on top. In an automated workcell, use a robotic arm to move the reader unit off the base before loading the plate.\n\n```\nByonoyLuminescenceBaseUnit (base)\n +-- plate_holder (assign plates here)\n +-- reader_unit_holder (reader unit sits here during measurement)\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ck97t28eylh", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "thvjaquzdj", + "source": "await reader.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/capabilities/absorbance.ipynb b/docs/user_guide/capabilities/absorbance.ipynb new file mode 100644 index 00000000000..fa2172aa4b0 --- /dev/null +++ b/docs/user_guide/capabilities/absorbance.ipynb @@ -0,0 +1,84 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Absorbance\n", + "\n", + "{class}`~pylabrobot.capabilities.plate_reading.absorbance.absorbance.Absorbance` reads absorbance (optical density) from microplates.\n", + "\n", + "## When to use\n", + "\n", + "Use this for OD measurements (cell density, ELISA readouts, protein quantification, etc.).\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.plate_reading.absorbance import Absorbance\n", + "from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import AbsorbanceChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "absorbance = Absorbance(backend=AbsorbanceChatterboxBackend())\n", + "await absorbance._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Read all wells at 450 nm\n", + "results = await absorbance.read(plate=plate, wavelength=450)\n", + "print(f\"A1 OD450: {results[0].data[0][0]}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Read specific wells\n", + "wells = [plate.get_well(\"A1\"), plate.get_well(\"B1\")]\n", + "results = await absorbance.read(plate=plate, wavelength=600, wells=wells)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} absorbance\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.plate_reading.absorbance.absorbance.Absorbance` and {class}`~pylabrobot.capabilities.plate_reading.absorbance.absorbance.AbsorbanceBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/arms.md b/docs/user_guide/capabilities/arms.md new file mode 100644 index 00000000000..f9a039122ae --- /dev/null +++ b/docs/user_guide/capabilities/arms.md @@ -0,0 +1,77 @@ +# Arms + +Arms are capabilities for picking up, moving, and placing labware (plates, lids, etc.) on the deck. PLR provides two arm types: + +- {class}`~pylabrobot.capabilities.arms.arm.GripperArm` -- a fixed-axis gripper arm (e.g. Hamilton core grippers). Grips along a single axis. +- {class}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm` -- a rotatable gripper arm (e.g. Hamilton iSWAP). Can grip from any direction. + +Both inherit from `_BaseArm`, which is a {class}`~pylabrobot.capabilities.capability.Capability`. + +## When to use + +Use arms to move plates, lids, and other labware between deck positions -- from a hotel to a reader, from a reader to a shaker, from a shaker to a centrifuge, etc. + +## Setup + +Arms are accessed as an attribute on a liquid handler or standalone arm device: + +```python +from pylabrobot.hamilton.star import STAR + +lh = STAR(name="star", ...) +await lh.setup() + +# the arm is at lh.iswap (OrientableArm) or lh.core_gripper (GripperArm) +await lh.iswap.move_resource(plate, to=heater_shaker) +``` + +## Walkthrough + +### Move a plate (one call) + +```python +await lh.iswap.move_resource(plate, to=heater_shaker) +``` + +### Move a plate (step by step) + +```python +await lh.iswap.pick_up_resource(plate) +await lh.iswap.drop_resource(heater_shaker) +``` + +### Move with intermediate waypoints + +```python +await lh.iswap.move_resource( + plate, + to=centrifuge_bucket, + intermediate_locations=[safe_height], # absolute coordinates +) +``` + +### OrientableArm: grip direction + +```python +from pylabrobot.capabilities.arms.standard import GripDirection + +await lh.iswap.pick_up_resource(plate, direction=GripDirection.LEFT) +await lh.iswap.drop_resource(reader, direction=GripDirection.FRONT) +``` + +## Tips and gotchas + +- **Coordinates are in the reference resource's frame** (typically the deck). The arm computes gripper target coordinates from the resource's position, dimensions, and the destination type. +- **`pickup_distance_from_bottom`** controls how far up from the bottom of the resource the gripper grips. If `None`, the resource's `preferred_pickup_location` is used, or a default of 5 mm from the top (`size_z - 5`). +- **Resource tree is updated automatically.** After a successful `drop_resource`, the resource is unassigned from its old parent and assigned to the destination. +- **`GripOrientation`** is either a {class}`~pylabrobot.capabilities.arms.standard.GripDirection` enum (`FRONT`, `RIGHT`, `BACK`, `LEFT`) or a float in degrees. +- **`request_gripper_location()`** queries the hardware for the current end effector position. `get_picked_up_resource()` returns the internally tracked state (no hardware call). + +## Supported hardware + +```{supported-devices} arm +``` + +## API reference + +See {class}`~pylabrobot.capabilities.arms.arm.GripperArm`, {class}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm`, {class}`~pylabrobot.capabilities.arms.backend.GripperArmBackend`, and {class}`~pylabrobot.capabilities.arms.backend.OrientableGripperArmBackend`. diff --git a/docs/user_guide/capabilities/automated-retrieval.ipynb b/docs/user_guide/capabilities/automated-retrieval.ipynb new file mode 100644 index 00000000000..698abc9eefb --- /dev/null +++ b/docs/user_guide/capabilities/automated-retrieval.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Automated Retrieval\n", + "\n", + "{class}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval` controls automated storage systems (carousels, plate hotels, incubators with internal storage) that can fetch and store plates.\n", + "\n", + "## When to use\n", + "\n", + "Use this to programmatically retrieve plates from storage for processing and return them when done.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval\n", + "from pylabrobot.capabilities.automated_retrieval.chatterbox import AutomatedRetrievalChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "retrieval = AutomatedRetrieval(backend=AutomatedRetrievalChatterboxBackend())\n", + "await retrieval._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"my_plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await retrieval.fetch_plate_to_loading_tray(plate)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} storage\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval` and {class}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrievalBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/barcode-scanning.ipynb b/docs/user_guide/capabilities/barcode-scanning.ipynb new file mode 100644 index 00000000000..cdba713d33a --- /dev/null +++ b/docs/user_guide/capabilities/barcode-scanning.ipynb @@ -0,0 +1,69 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Barcode Scanning\n", + "\n", + "{class}`~pylabrobot.capabilities.barcode_scanning.barcode_scanning.BarcodeScanner` reads barcodes from labware.\n", + "\n", + "## When to use\n", + "\n", + "Use this to identify plates, tubes, or other labware by their barcode during automated workflows -- for tracking, verification, or LIMS integration.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.barcode_scanning import BarcodeScanner\n", + "from pylabrobot.capabilities.barcode_scanning.chatterbox import BarcodeScannerChatterboxBackend\n", + "\n", + "scanner = BarcodeScanner(backend=BarcodeScannerChatterboxBackend())\n", + "await scanner._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "barcode = await scanner.scan()\n", + "print(f\"Scanned: {barcode}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} barcode scanning\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.barcode_scanning.barcode_scanning.BarcodeScanner` and {class}`~pylabrobot.capabilities.barcode_scanning.barcode_scanning.BarcodeScannerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/centrifuging.ipynb b/docs/user_guide/capabilities/centrifuging.ipynb new file mode 100644 index 00000000000..dda1ce5df3b --- /dev/null +++ b/docs/user_guide/capabilities/centrifuging.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Centrifuging\n", + "\n", + "{class}`~pylabrobot.capabilities.centrifuging.centrifuging.Centrifuge` controls automated centrifuges with door, bucket, and spin management.\n", + "\n", + "## When to use\n", + "\n", + "Use this for pelleting cells, separating phases, or any protocol step that requires centrifugal force.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.centrifuging import Centrifuge\n", + "from pylabrobot.capabilities.centrifuging.chatterbox import CentrifugeChatterboxBackend\n", + "from pylabrobot.resources import ResourceHolder\n", + "\n", + "bucket1 = ResourceHolder(name=\"bucket1\", size_x=100, size_y=100, size_z=50)\n", + "bucket2 = ResourceHolder(name=\"bucket2\", size_x=100, size_y=100, size_z=50)\n", + "\n", + "centrifuge = Centrifuge(\n", + " backend=CentrifugeChatterboxBackend(),\n", + " buckets=(bucket1, bucket2),\n", + ")\n", + "await centrifuge._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await centrifuge.open_door()\n", + "# ... load plate into bucket via arm ...\n", + "await centrifuge.close_door()\n", + "await centrifuge.lock_door()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Spin at 500 x g for 2 seconds (blocks until complete)\n", + "await centrifuge.spin(g=500, duration=2)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await centrifuge.unlock_door()\n", + "await centrifuge.open_door()\n", + "\n", + "# Bucket position is unknown after spinning\n", + "print(f\"at_bucket: {centrifuge.at_bucket}\")\n", + "\n", + "# Rotate to a known position before unloading\n", + "await centrifuge.go_to_bucket1()\n", + "print(f\"at_bucket: {centrifuge.at_bucket}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **`spin()` blocks** for the entire cycle (ramp up + time at speed + ramp down).\n", + "- **`duration` is time at speed**, excluding ramp-up and ramp-down.\n", + "- **Bucket position is unknown after spinning.** `at_bucket` resets to `None` after `spin()`.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} centrifuging\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.centrifuging.centrifuging.Centrifuge` and {class}`~pylabrobot.capabilities.centrifuging.centrifuging.CentrifugeBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/dispensing/index.md b/docs/user_guide/capabilities/dispensing/index.md new file mode 100644 index 00000000000..6a5fb7e80ba --- /dev/null +++ b/docs/user_guide/capabilities/dispensing/index.md @@ -0,0 +1,52 @@ +# Bulk dispensing + +Bulk dispensers deliver reagent into microplates at high throughput. They are used for filling plates with media, adding assay reagents, wash steps, and any operation where many wells need the same (or similar) volumes. + +PLR supports two dispensing mechanisms, each with its own capability: + +| Capability | Mechanism | Typical use | +|---|---|---| +| **[Peristaltic dispensing](peristaltic)** | Peristaltic pump | Media, wash buffer, large-volume reagents | +| **[Syringe dispensing](syringe)** | Syringe pump | Detection reagents, substrates, low-volume precision | + +Both capabilities share the same `volumes` interface: a dict mapping **1-indexed column numbers** to volumes in uL. Device-specific settings (pump speed, cassette type, flow rate, etc.) are passed as `backend_params`. + +Some devices (like the BioTek EL406) have both systems on a single instrument. Use the one that matches your volume and accuracy requirements. + +## Peristaltic vs syringe + +| | Peristaltic | Syringe | +|---|---|---| +| **Volume range** | Medium--high | Low--medium | +| **Accuracy** | Good | High | +| **Throughput** | High | Lower | +| **Purge needed** | Yes | No | + +Peristaltic dispensers push fluid through flexible tubing using a rotating pump head. They are fast and handle large volumes well, but require priming before use and purging after to clear the lines. Syringe dispensers aspirate a fixed volume into a barrel and dispense it with high precision. They are slower but more accurate at low volumes. + +## Tips and gotchas + +- **Always prime before dispensing** (peristaltic). Air in the tubing causes inaccurate volumes. +- **Purge after dispensing** to prevent reagent from drying in the lines. +- **Columns are 1-indexed.** `{1: 50.0}` sets column 1, not column 0. +- **Only columns in the dict are set.** Columns not in `volumes` retain their previous setting on the instrument. If in doubt, explicitly set all columns. + +## Supported hardware + +| Device | Manufacturer | Peristaltic | Syringe | +|--------|-------------|:-----------:|:-------:| +| [Multidrop Combi](../../thermo_fisher/multidrop_combi/hello-world) | Thermo Fisher | yes | -- | +| [EL406](../../agilent/biotek/el406/hello-world) | BioTek (Agilent) | yes | yes | + +```{toctree} +:maxdepth: 1 +:hidden: + +peristaltic +syringe +``` + +## API reference + +- {class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic8.PeristalticDispensing8` / {class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.backend8.PeristalticDispensingBackend8` +- {class}`~pylabrobot.capabilities.bulk_dispensers.syringe.syringe8.SyringeDispensing8` / {class}`~pylabrobot.capabilities.bulk_dispensers.syringe.backend8.SyringeDispensingBackend8` diff --git a/docs/user_guide/capabilities/dispensing/peristaltic.ipynb b/docs/user_guide/capabilities/dispensing/peristaltic.ipynb new file mode 100644 index 00000000000..1471b8e078f --- /dev/null +++ b/docs/user_guide/capabilities/dispensing/peristaltic.ipynb @@ -0,0 +1,115 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": "# Peristaltic dispensing\n\n{class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic8.PeristalticDispensing8` controls peristaltic pump dispensing systems for bulk reagent delivery into microplates.\n\nPeristaltic dispensers use a rotating pump head to push fluid through tubing. They offer high throughput and are ideal for media, wash buffer, and large-volume reagent addition.\n\n## Walkthrough" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6g7h8", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensing8\nfrom pylabrobot.capabilities.bulk_dispensers.peristaltic.chatterbox import (\n PeristalticDispensingChatterboxBackend8,\n)\nfrom pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nperistaltic = PeristalticDispensing8(backend=PeristalticDispensingChatterboxBackend8())\nawait peristaltic._on_setup()\n\nplate = Cor_96_wellplate_360ul_Fb(\"demo_plate\")" + }, + { + "cell_type": "markdown", + "id": "i9j0k1l2", + "metadata": {}, + "source": [ + "### Dispensing\n", + "\n", + "Pass a `volumes` dict mapping **1-indexed column numbers** to volumes in uL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m3n4o5p6", + "metadata": {}, + "outputs": [], + "source": [ + "# Uniform volume across all 12 columns\n", + "await peristaltic.dispense(\n", + " plate=plate,\n", + " volumes={col: 50.0 for col in range(1, 13)},\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "q7r8s9t0", + "metadata": {}, + "outputs": [], + "source": [ + "# Different volumes per column\n", + "await peristaltic.dispense(\n", + " plate=plate,\n", + " volumes={1: 10.0, 2: 20.0, 3: 30.0},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "u1v2w3x4", + "metadata": {}, + "source": [ + "### Priming\n", + "\n", + "Prime the fluid lines before dispensing to fill tubing with reagent. Specify either `volume` (uL) or `duration` (seconds), depending on what the hardware supports." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "y5z6a7b8", + "metadata": {}, + "outputs": [], + "source": [ + "await peristaltic.prime(plate=plate, volume=500.0)" + ] + }, + { + "cell_type": "markdown", + "id": "c9d0e1f2", + "metadata": {}, + "source": [ + "### Purging\n", + "\n", + "Purge (empty) the fluid lines after dispensing to clear the tubing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "g3h4i5j6", + "metadata": {}, + "outputs": [], + "source": [ + "await peristaltic.purge(plate=plate, volume=500.0)" + ] + }, + { + "cell_type": "markdown", + "id": "k7l8m9n0", + "metadata": {}, + "source": "## Tips and gotchas\n\n- **Always prime before dispensing.** Air in the tubing causes inaccurate volumes.\n- **Purge after dispensing** to prevent reagent from drying in the lines.\n- **Columns are 1-indexed.** `{1: 50.0}` sets column 1, not column 0.\n- **Only columns in the dict are set.** Columns not in `volumes` retain their previous setting on the instrument.\n\n## Supported hardware\n\n| Device | Manufacturer |\n|--------|-------------|\n| [Multidrop Combi](../../thermo_fisher/multidrop_combi/hello-world) | Thermo Fisher |\n| [EL406](../../agilent/biotek/el406/hello-world) | BioTek (Agilent) |\n\n## API reference\n\nSee {class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic8.PeristalticDispensing8` and {class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.backend8.PeristalticDispensingBackend8`." + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/dispensing/syringe.ipynb b/docs/user_guide/capabilities/dispensing/syringe.ipynb new file mode 100644 index 00000000000..0f734e3783b --- /dev/null +++ b/docs/user_guide/capabilities/dispensing/syringe.ipynb @@ -0,0 +1,80 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": "# Syringe dispensing\n\n{class}`~pylabrobot.capabilities.bulk_dispensers.syringe.syringe8.SyringeDispensing8` controls syringe pump dispensing systems for precise, low-volume reagent delivery into microplates.\n\nSyringe dispensers aspirate a fixed volume into a syringe barrel, then dispense it with high accuracy. They are ideal for expensive reagents, substrates, or detection reagents where precision matters.\n\n## Walkthrough" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6g7h8", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.capabilities.bulk_dispensers.syringe import SyringeDispensing8\nfrom pylabrobot.capabilities.bulk_dispensers.syringe.chatterbox import (\n SyringeDispensingChatterboxBackend8,\n)\nfrom pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nsyringe = SyringeDispensing8(backend=SyringeDispensingChatterboxBackend8())\nawait syringe._on_setup()\n\nplate = Cor_96_wellplate_360ul_Fb(\"demo_plate\")" + }, + { + "cell_type": "markdown", + "id": "i9j0k1l2", + "metadata": {}, + "source": [ + "### Dispensing\n", + "\n", + "Pass a `volumes` dict mapping **1-indexed column numbers** to volumes in uL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m3n4o5p6", + "metadata": {}, + "outputs": [], + "source": [ + "await syringe.dispense(\n", + " plate=plate,\n", + " volumes={col: 50.0 for col in range(1, 13)},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "q7r8s9t0", + "metadata": {}, + "source": [ + "### Priming\n", + "\n", + "Prime the syringe before dispensing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "u1v2w3x4", + "metadata": {}, + "outputs": [], + "source": [ + "await syringe.prime(plate=plate, volume=5000.0)" + ] + }, + { + "cell_type": "markdown", + "id": "y5z6a7b8", + "metadata": {}, + "source": "## Peristaltic vs syringe\n\n| | Peristaltic | Syringe |\n|---|---|---|\n| **Volume range** | Medium--high | Low--medium |\n| **Accuracy** | Good | High |\n| **Throughput** | High | Lower |\n| **Purge needed** | Yes | No |\n| **Typical use** | Media, wash buffer | Detection reagents, substrates |\n\nSome devices (like the BioTek EL406) have both. Use the one that matches your volume and accuracy requirements.\n\n## Supported hardware\n\n| Device | Manufacturer |\n|--------|-------------|\n| [EL406](../../agilent/biotek/el406/hello-world) | BioTek (Agilent) |\n\n## API reference\n\nSee {class}`~pylabrobot.capabilities.bulk_dispensers.syringe.syringe8.SyringeDispensing8` and {class}`~pylabrobot.capabilities.bulk_dispensers.syringe.backend8.SyringeDispensingBackend8`." + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/fan-control.ipynb b/docs/user_guide/capabilities/fan-control.ipynb new file mode 100644 index 00000000000..773a1f7b83a --- /dev/null +++ b/docs/user_guide/capabilities/fan-control.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Fans (Air Filtration Systems)\n", + "\n", + "{class}`~pylabrobot.capabilities.fan_control.fan_control.Fan` controls air filtration units that condition air within or around the deck to protect samples from contamination.\n", + "\n", + "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.\n", + "These systems are not primarily designed for operator safety; separate equipment like fume extractors or biosafety cabinets serves that role.\n", + "\n", + "Common filter technologies include:\n", + "\n", + "- **HEPA filters**: Capture \u226599.97% of airborne particles \u22650.3 \u00b5m, widely used to keep samples clean.\n", + "- **ULPA filters**: Capture even smaller particles for higher-level cleanroom requirements.\n", + "- **Activated carbon filters**: Remove volatile organic compounds (VOCs) and chemical fumes.\n", + "- **Prefilters**: Trap larger particles to extend the lifespan of HEPA/ULPA filters.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.fan_control import Fan\n", + "from pylabrobot.capabilities.fan_control.chatterbox import FanChatterboxBackend\n", + "\n", + "fan = Fan(backend=FanChatterboxBackend())\n", + "await fan._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run at 80% (returns immediately)\n", + "await fan.turn_on(intensity=80)\n", + "await fan.turn_off()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run for a fixed duration (blocks)\n", + "await fan.turn_on(intensity=100, duration=2)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **Intensity is 0--100** (percent).\n", + "- **With `duration`: blocks. Without: returns immediately.**\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} air filtration\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.fan_control.fan_control.Fan` and {class}`~pylabrobot.capabilities.fan_control.fan_control.FanBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/fluorescence.ipynb b/docs/user_guide/capabilities/fluorescence.ipynb new file mode 100644 index 00000000000..85d7828d498 --- /dev/null +++ b/docs/user_guide/capabilities/fluorescence.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Fluorescence\n", + "\n", + "{class}`~pylabrobot.capabilities.plate_reading.fluorescence.fluorescence.Fluorescence` reads fluorescence intensity from microplates.\n", + "\n", + "## When to use\n", + "\n", + "Use this for fluorescence-based assays (GFP expression, DNA quantification with intercalating dyes, fluorescent ELISAs, etc.).\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence\n", + "from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import FluorescenceChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "fluorescence = Fluorescence(backend=FluorescenceChatterboxBackend())\n", + "await fluorescence._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "results = await fluorescence.read(\n", + " plate=plate,\n", + " excitation_wavelength=485,\n", + " emission_wavelength=528,\n", + " focal_height=8.5,\n", + ")\n", + "print(f\"A1 fluorescence: {results[0].data[0][0]} RFU\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} fluorescence\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.plate_reading.fluorescence.fluorescence.Fluorescence` and {class}`~pylabrobot.capabilities.plate_reading.fluorescence.fluorescence.FluorescenceBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/head96.md b/docs/user_guide/capabilities/head96.md new file mode 100644 index 00000000000..7e63775ff7e --- /dev/null +++ b/docs/user_guide/capabilities/head96.md @@ -0,0 +1,66 @@ +# Head96 (96-Channel Head) + +{class}`~pylabrobot.capabilities.liquid_handling.head96.Head96` controls a 96-channel pipetting head that operates on full tip racks and plates at once. All 96 channels move together as a single unit. + +## When to use + +Use this for plate-to-plate transfers, plate replication, or any operation where all 96 wells are processed identically. Much faster than using independent channels for full-plate operations. + +## Setup + +```python +from pylabrobot.hamilton.star import STAR + +lh = STAR(name="star", ...) +await lh.setup() + +# 96-head is at lh.head96 +await lh.head96.pick_up_tips(tip_rack) +``` + +## Walkthrough + +### Plate-to-plate transfer + +```python +await lh.head96.pick_up_tips(tip_rack) +await lh.head96.aspirate(source_plate, volume=50) +await lh.head96.dispense(target_plate, volume=50) +await lh.head96.return_tips() +``` + +### Stamp (one-liner plate copy) + +```python +await lh.head96.pick_up_tips(tip_rack) +await lh.head96.stamp(source_plate, target_plate, volume=50) +await lh.head96.discard_tips(trash) +``` + +### Aspirating from a trough + +```python +# All 96 tips dip into the same container +await lh.head96.pick_up_tips(tip_rack) +await lh.head96.aspirate(trough, volume=200) +await lh.head96.dispense(plate, volume=200) +await lh.head96.discard_tips(trash) +``` + +## Tips and gotchas + +- **Volumes are in uL, flow rates in uL/s, heights in mm.** +- **Sparse pickup is supported.** If a tip rack is partially empty, only the positions that have tips are picked up. +- **Trough minimum size.** When aspirating from a single container, it must be at least ~101 mm x ~65 mm to accommodate the 96-head geometry (9 mm tip spacing). +- **`stamp` requires same-shape plates.** Both plates must have the same `num_items_x` and `num_items_y`. +- **`return_tips` requires all tips from the same rack.** Raises `RuntimeError` if mounted tips originated from different racks. +- **A `default_offset`** (set at construction) is added to all operation offsets. + +## Supported hardware + +```{supported-devices} liquid handling +``` + +## API reference + +See {class}`~pylabrobot.capabilities.liquid_handling.head96.Head96` and {class}`~pylabrobot.capabilities.liquid_handling.head96.Head96Backend`. diff --git a/docs/user_guide/capabilities/humidity-control.ipynb b/docs/user_guide/capabilities/humidity-control.ipynb new file mode 100644 index 00000000000..75aeeec8eaa --- /dev/null +++ b/docs/user_guide/capabilities/humidity-control.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Humidity Control\n", + "\n", + "{class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` reads and sets relative humidity for incubators and environmental chambers.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.humidity_controlling import HumidityController\n", + "from pylabrobot.capabilities.humidity_controlling.chatterbox import HumidityControllerChatterboxBackend\n", + "\n", + "humid = HumidityController(backend=HumidityControllerChatterboxBackend())\n", + "await humid._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await humid.set_humidity(0.95) # 95% RH\n", + "current = await humid.request_humidity()\n", + "print(f\"Humidity: {current * 100:.1f}%\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **Humidity is a fraction (0.0--1.0)**, not a percentage.\n", + "- **Some backends are read-only.** If `supports_humidity_control` is `False`, calling `set_humidity` raises `ValueError`.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} humidity\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` and {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityControllerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md new file mode 100644 index 00000000000..2a091c83f64 --- /dev/null +++ b/docs/user_guide/capabilities/index.md @@ -0,0 +1,67 @@ +# Capabilities + +Capabilities are the building blocks of device functionality in PyLabRobot. Each capability defines a standard interface for a specific type of lab operation (e.g. temperature control, shaking, plate reading), decoupled from any particular hardware. + +A single device can expose multiple capabilities -- and capabilities can be optional (`None` if the hardware doesn't support it) or even duplicated (e.g. a device with two independent arms). For example, a heater-shaker exposes both a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`. + +## Architecture + +``` +Device + ├── driver (hardware communication) + └── capabilities + ├── TemperatureController (backend) + ├── Shaker (backend) + └── ... +``` + +Each capability has two layers: a **frontend** and a **backend**. + +### Frontend (the capability class) + +The frontend is what you interact with as a user. It provides: + +- A **stable, hardware-agnostic API** -- the same `set_temperature(37.0)` call works regardless of whether you're using an Inheco ThermoShake or a Hamilton Heater Cooler. +- **Validation** -- checking that arguments are in range, that the device is ready, that preconditions are met (e.g. you can't `wait_for_temperature` without first setting a target). +- **State tracking** -- keeping track of which tips are mounted, what the current tilt angle is, whether the door is open, etc. +- **Convenience methods** -- higher-level operations like `stamp` (aspirate + dispense) or `transfer` (one-to-many) built on top of the primitive backend calls. + +The frontend is the same across all hardware that supports the capability. + +### Backend (the hardware-specific implementation) + +The backend is what talks to the actual hardware. Each capability defines an abstract backend class (e.g. {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureControllerBackend`, {class}`~pylabrobot.capabilities.shaking.shaking.ShakerBackend`) that specifies the methods a hardware driver must implement. + +Backend methods are lower-level and closer to the wire protocol. For example, where the frontend {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`.`shake(speed, duration)` handles timing and auto-stop, the backend only needs to implement `start_shaking(speed)` and `stop_shaking()`. + +To add support for a new piece of hardware, you implement the backend interface for the capabilities it supports. The frontend takes care of the rest. + +All capability methods are `async` and require the parent device to be set up before use (enforced by the `@need_capability_ready` decorator). + +## Available capabilities + +```{toctree} +:maxdepth: 1 + +temperature-control +shaking +fan-control +humidity-control +centrifuging +sealing +peeling +tilting +loading-tray +pumping +weighing +barcode-scanning +microscopy +automated-retrieval +absorbance +fluorescence +luminescence +dispensing/index +pip +head96 +arms +``` diff --git a/docs/user_guide/capabilities/loading-tray.ipynb b/docs/user_guide/capabilities/loading-tray.ipynb new file mode 100644 index 00000000000..96bfd39573a --- /dev/null +++ b/docs/user_guide/capabilities/loading-tray.ipynb @@ -0,0 +1,127 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Loading tray\n", + "\n", + "{class}`~pylabrobot.capabilities.loading_tray.loading_tray.LoadingTray` controls motorized loading trays that open and close to accept plates. It doubles as a {class}`~pylabrobot.resources.resource_holder.ResourceHolder`, so plates can be assigned directly to the tray.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.capabilities.loading_tray import LoadingTray\n", + "from pylabrobot.capabilities.loading_tray.chatterbox import LoadingTrayChatterboxBackend\n", + "\n", + "loading_tray = LoadingTray(\n", + " backend=LoadingTrayChatterboxBackend(),\n", + " name=\"loading_tray\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=0,\n", + ")\n", + "await loading_tray._on_setup()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-2", + "metadata": {}, + "outputs": [], + "source": [ + "await loading_tray.open()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-3", + "metadata": {}, + "source": [ + "Assign a plate to the tray while it is open:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-4", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "loading_tray.assign_child_resource(plate)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "await loading_tray.close()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "## Backend-specific parameters\n", + "\n", + "Some backends accept extra parameters via `backend_params`. For example, BioTek devices support a `slow` flag:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek import BioTekLoadingTrayBackend\n", + "\n", + "await loading_tray.open(backend_params=BioTekLoadingTrayBackend.OpenParams(slow=True))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} loading_tray\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.loading_tray.loading_tray.LoadingTray` and {class}`~pylabrobot.capabilities.loading_tray.backend.LoadingTrayBackend`." + ] + } + ], + "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/capabilities/luminescence.ipynb b/docs/user_guide/capabilities/luminescence.ipynb new file mode 100644 index 00000000000..a869333d7ca --- /dev/null +++ b/docs/user_guide/capabilities/luminescence.ipynb @@ -0,0 +1,72 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Luminescence\n", + "\n", + "{class}`~pylabrobot.capabilities.plate_reading.luminescence.luminescence.Luminescence` reads luminescence from microplates.\n", + "\n", + "## When to use\n", + "\n", + "Use this for luminescence-based assays (luciferase reporters, ATP quantification, chemiluminescent ELISAs, etc.).\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.plate_reading.luminescence import Luminescence\n", + "from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import LuminescenceChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "luminescence = Luminescence(backend=LuminescenceChatterboxBackend())\n", + "await luminescence._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "results = await luminescence.read(plate=plate, focal_height=8.5)\n", + "print(f\"A1 luminescence: {results[0].data[0][0]} RLU\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} luminescence\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.plate_reading.luminescence.luminescence.Luminescence` and {class}`~pylabrobot.capabilities.plate_reading.luminescence.luminescence.LuminescenceBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/microscopy.md b/docs/user_guide/capabilities/microscopy.md new file mode 100644 index 00000000000..5eef8675916 --- /dev/null +++ b/docs/user_guide/capabilities/microscopy.md @@ -0,0 +1,93 @@ +# Microscopy + +{class}`~pylabrobot.capabilities.microscopy.microscopy.Microscopy` controls automated microscopes with support for PLR-driven auto-exposure and auto-focus. + +## When to use + +Use this for imaging cells, colonies, crystals, or any sample in microplates -- with automated focus and exposure optimization. + +## Setup + +```python +from pylabrobot.molecular_devices.imageXpress.pico import Pico + +microscope = Pico(name="pico", ...) +await microscope.setup() +``` + +## Walkthrough + +### Basic capture + +```python +result = await microscope.microscope.capture( + well=plate.get_well("A1"), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=plate, +) +# result.images contains the captured data +# result.exposure_time and result.focal_height report what was used +``` + +### Auto-exposure + +PLR can optimize exposure time via a binary search. You provide a callback that evaluates whether an image is under-, over-, or correctly exposed: + +```python +from pylabrobot.capabilities.microscopy.microscopy import AutoExposure, max_pixel_at_fraction + +result = await microscope.microscope.capture( + well=plate.get_well("A1"), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=plate, + exposure_time=AutoExposure( + evaluate_exposure=max_pixel_at_fraction(0.8, margin=0.05), + low=1.0, # min exposure (ms) + high=500.0, # max exposure (ms) + max_rounds=10, + ), + focal_height=5.0, # must be numeric when using AutoExposure + gain=1.0, # must be numeric when using AutoExposure +) +``` + +### Auto-focus + +PLR can optimize focal height via a golden-ratio search: + +```python +from pylabrobot.capabilities.microscopy.microscopy import AutoFocus, evaluate_focus_nvmg_sobel + +result = await microscope.microscope.capture( + well=(0, 0), # can also pass a (row, col) tuple + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=plate, + exposure_time=50.0, # must be numeric when using AutoFocus + focal_height=AutoFocus( + evaluate_focus=evaluate_focus_nvmg_sobel, + low=0.0, # min focal height (mm) + high=10.0, # max focal height (mm) + tolerance=0.01, # convergence tolerance (mm) + timeout=60, # seconds + ), + gain=1.0, # must be numeric when using AutoFocus +) +``` + +## Tips and gotchas + +- **When using `AutoExposure`, `focal_height` and `gain` must be numeric** (not `"machine-auto"` or `AutoFocus`). Same constraint applies in reverse for `AutoFocus`. +- **`"machine-auto"` defers to the microscope's built-in defaults.** Use this when you don't need PLR-driven optimization. +- **`evaluate_focus_nvmg_sobel`** computes focus quality using a Sobel filter on the center 50% of the image. Higher scores mean sharper focus. + +## Supported hardware + +```{supported-devices} microscopy +``` + +## API reference + +See {class}`~pylabrobot.capabilities.microscopy.microscopy.Microscopy` and {class}`~pylabrobot.capabilities.microscopy.microscopy.MicroscopyBackend`. diff --git a/docs/user_guide/capabilities/peeling.ipynb b/docs/user_guide/capabilities/peeling.ipynb new file mode 100644 index 00000000000..5e3b59e2f55 --- /dev/null +++ b/docs/user_guide/capabilities/peeling.ipynb @@ -0,0 +1,64 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Peeling\n", + "\n", + "{class}`~pylabrobot.capabilities.peeling.peeling.Peeler` controls automated de-seal (peel) cycles for removing plate seals.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.peeling import Peeler\n", + "from pylabrobot.capabilities.peeling.chatterbox import PeelerChatterboxBackend\n", + "\n", + "peeler = Peeler(backend=PeelerChatterboxBackend())\n", + "await peeler._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await peeler.peel()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} peeling\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.peeling.peeling.Peeler` and {class}`~pylabrobot.capabilities.peeling.peeling.PeelerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/pip.md b/docs/user_guide/capabilities/pip.md new file mode 100644 index 00000000000..34c060db143 --- /dev/null +++ b/docs/user_guide/capabilities/pip.md @@ -0,0 +1,89 @@ +# PIP (Independent Channels) + +{class}`~pylabrobot.capabilities.liquid_handling.pip.PIP` controls independent pipetting channels for tip handling, aspiration, and dispensing. + +## When to use + +Use this for any pipetting operation that uses individual channels: serial dilutions, cherry-picking, reformatting between plates, etc. + +## Setup + +PIP is accessed as an attribute on a liquid handler: + +```python +from pylabrobot.hamilton.star import STAR + +lh = STAR(name="star", ...) +await lh.setup() + +# independent channels are at lh.pip +await lh.pip.pick_up_tips(tip_rack["A1:H1"]) +``` + +## Walkthrough + +### Basic pipetting + +```python +# Pick up 8 tips +await lh.pip.pick_up_tips(tip_rack["A1:H1"]) + +# Aspirate 100 uL from column 1 +await lh.pip.aspirate(plate["A1:H1"], vols=[100] * 8) + +# Dispense 100 uL into column 2 +await lh.pip.dispense(plate["A2:H2"], vols=[100] * 8) + +# Return tips to where they came from +await lh.pip.return_tips() +``` + +### Using specific channels + +```python +# Use only channels 0 and 1 +with lh.pip.use_channels([0, 1]): + await lh.pip.pick_up_tips([tip_rack["A1"], tip_rack["B1"]]) + await lh.pip.aspirate([plate["A1"], plate["B1"]], vols=[50, 50]) + await lh.pip.dispense([plate["A2"], plate["B2"]], vols=[50, 50]) + await lh.pip.drop_tips([tip_rack["A1"], tip_rack["B1"]]) +``` + +### Automatic tip management + +```python +# use_tips picks up on entry, discards on exit +async with lh.pip.use_tips(tip_rack["A1:H1"], trash=trash): + await lh.pip.aspirate(plate["A1:H1"], vols=[100] * 8) + await lh.pip.dispense(plate["A2:H2"], vols=[100] * 8) +# tips are discarded automatically +``` + +### One-to-many transfer + +```python +# Aspirate from one well, distribute to multiple targets +await lh.pip.transfer( + source=plate["A1"], + targets=[plate["B1"], plate["C1"], plate["D1"]], + source_vol=300, # aspirate 300 uL total + ratios=[1, 1, 1], # equal distribution (100 uL each) +) +``` + +## Tips and gotchas + +- **Volumes are in uL, flow rates in uL/s, heights in mm, offsets in mm.** +- **`spread` mode** controls how channels are positioned when aspirating/dispensing from a single container: `"wide"` maximizes spacing, `"tight"` minimizes it, `"custom"` uses your offsets. +- **Tip tracking is transactional.** If a multi-channel operation partially fails, only the channels that succeeded are committed. The rest are rolled back. +- **Volume tracking.** The capability tracks liquid volumes per tip and per well. `allow_nonzero_volume=False` (default on `drop_tips`) prevents you from dropping tips that still have liquid. +- **`discard_tips` defaults to `allow_nonzero_volume=True`**, since discarding tips with residual liquid is common. + +## Supported hardware + +```{supported-devices} liquid handling +``` + +## API reference + +See {class}`~pylabrobot.capabilities.liquid_handling.pip.PIP` and {class}`~pylabrobot.capabilities.liquid_handling.pip.PIPBackend`. diff --git a/docs/user_guide/capabilities/pumping.ipynb b/docs/user_guide/capabilities/pumping.ipynb new file mode 100644 index 00000000000..b7c51196f81 --- /dev/null +++ b/docs/user_guide/capabilities/pumping.ipynb @@ -0,0 +1,100 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Pumping\n", + "\n", + "{class}`~pylabrobot.capabilities.pumping.pumping.Pump` controls peristaltic and syringe pumps for fluid delivery.\n", + "\n", + "## When to use\n", + "\n", + "Use this for bulk liquid delivery, reagent dispensing, or continuous flow applications where pipetting is impractical.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.pumping import Pump\n", + "from pylabrobot.capabilities.pumping.chatterbox import PumpChatterboxBackend\n", + "\n", + "pump = Pump(backend=PumpChatterboxBackend())\n", + "await pump._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run for a fixed number of revolutions (blocks)\n", + "await pump.run_revolutions(10)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run continuously (returns immediately), then stop\n", + "await pump.run_continuously(50)\n", + "await pump.halt()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run for a fixed duration (blocks)\n", + "await pump.run_for_duration(speed=30, duration=2)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Calibration\n", + "\n", + "To use `pump_volume`, the capability needs a {class}`~pylabrobot.capabilities.pumping.calibration.PumpCalibration`. The calibration converts a (speed, volume) pair into either a duration or a number of revolutions.\n", + "\n", + "## Tips and gotchas\n", + "\n", + "- **`run_continuously` returns immediately.** The pump runs until you call `halt()`.\n", + "- **`run_revolutions` and `run_for_duration` block.**\n", + "- **`pump_volume` requires a calibration.** Raises `NotCalibratedError` if none is set.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} pumping\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.pumping.pumping.Pump` and {class}`~pylabrobot.capabilities.pumping.pumping.PumpBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/sealing.ipynb b/docs/user_guide/capabilities/sealing.ipynb new file mode 100644 index 00000000000..2386439a936 --- /dev/null +++ b/docs/user_guide/capabilities/sealing.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sealing\n", + "\n", + "{class}`~pylabrobot.capabilities.sealing.sealing.Sealer` controls thermal and adhesive plate sealers.\n", + "\n", + "In automated wet lab workflows, microplate sealers are essential for preserving sample integrity.\n", + "They prevent evaporation, cross-contamination, and spillage, especially during heating, shaking, storage, or robotic transport.\n", + "\n", + "## Types of sealers\n", + "\n", + "### Thermal sealers\n", + "\n", + "These use heat and pressure to bond a sealing film (typically foil or heat-reactive plastic) to the top of the microplate.\n", + "\n", + "- **Best for**: Long-term storage, PCR/qPCR workflows, high-integrity applications\n", + "- **Pros**: Very strong seal; compatible with a wide range of films\n", + "- **Cons**: Slower sealing time (typically 5--10 seconds per plate); requires warm-up time; when peeled, thermal seals may remove well material\n", + "\n", + "### Adhesive (pressure) sealers\n", + "\n", + "These apply pre-cut adhesive seals to the plate using downward mechanical pressure.\n", + "They do **not** use heat, making them faster and simpler for certain workflows.\n", + "\n", + "- **Best for**: Medium-throughput workflows, frequent access, short-term incubation\n", + "- **Pros**: Faster (as low as 1--2 seconds per plate); no warm-up period; compatible with repeelable seals\n", + "- **Cons**: Weaker seal compared to thermal; not suitable for long-term storage or high-temperature protocols\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.sealing import Sealer\n", + "from pylabrobot.capabilities.sealing.chatterbox import SealerChatterboxBackend\n", + "\n", + "sealer = Sealer(backend=SealerChatterboxBackend())\n", + "await sealer._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await sealer.open()\n", + "# ... place plate on shuttle via arm ...\n", + "await sealer.close()\n", + "await sealer.seal(temperature=170, duration=3)\n", + "await sealer.open()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} sealing\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.sealing.sealing.Sealer` and {class}`~pylabrobot.capabilities.sealing.sealing.SealerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/shaking.ipynb b/docs/user_guide/capabilities/shaking.ipynb new file mode 100644 index 00000000000..b7adde36f75 --- /dev/null +++ b/docs/user_guide/capabilities/shaking.ipynb @@ -0,0 +1,117 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Shaking\n", + "\n", + "{class}`~pylabrobot.capabilities.shaking.shaking.Shaker` controls orbital and linear shakers, with optional plate locking.\n", + "\n", + "## When to use\n", + "\n", + "Use this for mixing samples, resuspending pellets, or keeping suspensions homogeneous during incubation.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.shaking import Shaker\n", + "from pylabrobot.capabilities.shaking.chatterbox import ShakerChatterboxBackend\n", + "\n", + "shaker = Shaker(backend=ShakerChatterboxBackend())\n", + "await shaker._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Shake for a fixed duration (blocks for the full 60 seconds):" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await shaker.shake(speed=500, duration=2) # 2 seconds for demo" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Or start shaking indefinitely (returns immediately), then stop manually:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await shaker.shake(speed=300)\n", + "# ... do other things ...\n", + "await shaker.stop_shaking()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lock/unlock the plate (when the backend supports it):" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await shaker.lock_plate()\n", + "await shaker.shake(speed=1000, duration=1)\n", + "await shaker.unlock_plate()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **With `duration`: blocks. Without: returns immediately.**\n", + "- **Auto-locking.** When the backend supports it, `shake()` automatically locks before shaking and unlocks after (when `duration` is set).\n", + "- **Speed is in RPM.**\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} shaking\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` and {class}`~pylabrobot.capabilities.shaking.shaking.ShakerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/temperature-control.ipynb b/docs/user_guide/capabilities/temperature-control.ipynb new file mode 100644 index 00000000000..e7becacb89f --- /dev/null +++ b/docs/user_guide/capabilities/temperature-control.ipynb @@ -0,0 +1,127 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Temperature Control\n", + "\n", + "{class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` provides heating and (optionally) active cooling. It is one of the most widely used capabilities -- found on heater-shakers, incubators, plate readers, and standalone thermal modules.\n", + "\n", + "Temperature controllers are machines that can heat and/or actively cool a material or enclosed volume from a room-temperature baseline (~20--25 \u00b0C). Multi-functional machines like heater-shakers, qPCR instruments, and smart storage systems build on top of this capability.\n", + "\n", + "## Actuation technologies\n", + "\n", + "Multiple technologies can be used to implement temperature control:\n", + "\n", + "- **Thermoelectric (Peltier) modules** -- 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. *Pros:* bidirectional control, fast response, minimal footprint. *Cons:* limited \u0394T from ambient (~\u00b165 \u00b0C max), efficiency drops near extremes.\n", + "\n", + "- **Liquid-circulation systems** -- external chillers or heaters pump fluid (water or glycol) through channels around a sample block, delivering uniform, stable temperatures well below and above ambient. *Pros:* broad temperature range, excellent uniformity. *Cons:* bulky, requires plumbing.\n", + "\n", + "## When to use\n", + "\n", + "Use this capability whenever you need to hold labware at a specific temperature: incubation at 37 \u00b0C, enzyme inactivation at 65 \u00b0C, keeping reagents cold at 4 \u00b0C, etc.\n", + "\n", + "## Walkthrough\n", + "\n", + "In this example we use a chatterbox (simulated) backend. On real hardware, the capability is accessed as an attribute on a device (e.g. `hs.tc` on a Hamilton Heater Shaker)." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.temperature_controlling import TemperatureController\n", + "from pylabrobot.capabilities.temperature_controlling.chatterbox import TemperatureControllerChatterboxBackend\n", + "\n", + "tc = TemperatureController(backend=TemperatureControllerChatterboxBackend())\n", + "await tc._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set a target temperature and wait for it to stabilize. `set_temperature` sends the command and returns immediately. `wait_for_temperature` polls every 1 second until the target is reached (within `tolerance`)." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await tc.set_temperature(37.0)\n", + "await tc.wait_for_temperature(tolerance=0.5)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Read the current temperature at any time:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "current = await tc.request_temperature()\n", + "print(f\"{current:.1f} \u00b0C\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Deactivate to stop heating/cooling and return to ambient. This resets `target_temperature` to `None`." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await tc.deactivate()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **`set_temperature` does not wait.** It sends the command and returns. Use `wait_for_temperature` to block until the target is reached.\n", + "- **Passive cooling.** If you set a target below the current temperature and the backend doesn't support active cooling, you'll get a `ValueError`. Pass `passive=True` to allow the device to cool naturally.\n", + "- **`wait_for_temperature` polls every 1 second.** It raises `TimeoutError` after `timeout` seconds (default 300) and `RuntimeError` if no target has been set.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} heating, cooling\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureControllerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/tilting.ipynb b/docs/user_guide/capabilities/tilting.ipynb new file mode 100644 index 00000000000..b08329c1aa0 --- /dev/null +++ b/docs/user_guide/capabilities/tilting.ipynb @@ -0,0 +1,92 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tilting\n", + "\n", + "{class}`~pylabrobot.capabilities.tilting.tilting.Tilter` controls tilt modules for angling plates or other labware.\n", + "\n", + "## When to use\n", + "\n", + "Use this to tilt plates for aspiration from low-volume wells, gravity-based liquid removal, or bead settling.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.tilting import Tilter\n", + "from pylabrobot.capabilities.tilting.chatterbox import TilterChatterboxBackend\n", + "\n", + "tilter = Tilter(backend=TilterChatterboxBackend())\n", + "await tilter._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Tilt to an absolute angle (0 = horizontal)\n", + "await tilter.set_angle(15)\n", + "print(f\"angle: {tilter.absolute_angle}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Tilt by a relative amount\n", + "await tilter.tilt(-5)\n", + "print(f\"angle: {tilter.absolute_angle}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Return to horizontal\n", + "await tilter.set_angle(0)\n", + "print(f\"angle: {tilter.absolute_angle}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} tilting\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.tilting.tilting.Tilter` and {class}`~pylabrobot.capabilities.tilting.tilting.TilterBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/weighing.md b/docs/user_guide/capabilities/weighing.md new file mode 100644 index 00000000000..6ef55ea619f --- /dev/null +++ b/docs/user_guide/capabilities/weighing.md @@ -0,0 +1,96 @@ +# Weighing + +{class}`~pylabrobot.capabilities.weighing.weighing.Scale` controls laboratory balances and 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. + +--- + +## 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. + +--- + +## Methods + +- **`zero() -> None`** -- 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. + +```{figure} ../02_analytical/scales/img/scale_0_zero_example.png +:alt: Zero operation example +:align: center +``` + +- **`tare() -> None`** -- 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. + +```{figure} ../02_analytical/scales/img/scale_1_tare_example.png +:alt: Tare operation example +:align: center +``` + +- **`read_weight() -> float`** -- Retrieves the current weight measurement from the scale in grams. 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. + +```{figure} ../02_analytical/scales/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:** + +```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 +``` + +--- + +## Example + +```python +# on a scale: scale + +await scale.zero() +await scale.tare() # with container on scale + +weight = await scale.read_weight() +print(f"Net weight: {weight} g") +``` + +## Backend interface + +Implement {class}`~pylabrobot.capabilities.weighing.weighing.ScaleBackend`: + +- **`zero() -> None`** -- Zero the hardware. +- **`tare() -> None`** -- Tare the hardware. +- **`read_weight() -> float`** -- Return weight in grams. diff --git a/docs/user_guide/hamilton/heater_cooler/hello-world.ipynb b/docs/user_guide/hamilton/heater_cooler/hello-world.ipynb new file mode 100644 index 00000000000..969f0e1f97a --- /dev/null +++ b/docs/user_guide/hamilton/heater_cooler/hello-world.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hamilton Heater Cooler (HHC)\n", + "\n", + "The Hamilton Heater Cooler is a Peltier-based [temperature controller](../../capabilities/temperature-control) for microplates. It connects to a STAR liquid handler via RS-232 through a TCC port.\n", + "\n", + "| Spec | Value |\n", + "|---|---|\n", + "| PLR Name | `HamiltonHeaterCooler` |\n", + "| Temperature range | 0 to 110 \u00b0C |\n", + "| Cooling | Active (Peltier) |\n", + "| Communication | STAR TCC port (RS-232) |\n", + "\n", + "See the [OEM page](https://www.hamiltoncompany.com/temperature-control/hamilton-heater-cooler) for hardware details." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "The HHC is accessed through a STAR backend. Pass the backend and the TCC device number (1-based) to create the device." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.heater_cooler import HamiltonHeaterCooler\n", + "\n", + "# Assumes `star` is an already-setup STAR backend\n", + "hhc = HamiltonHeaterCooler(name=\"hhc\", star_backend=star, device_number=1)\n", + "await hhc.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "The HHC exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `hhc.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await hhc.tc.set_temperature(37.0)\n", + "await hhc.tc.wait_for_temperature(tolerance=0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await hhc.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await hhc.tc.deactivate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await hhc.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/hamilton/heater_shaker/hello-world.ipynb b/docs/user_guide/hamilton/heater_shaker/hello-world.ipynb new file mode 100644 index 00000000000..1b3c09b0d51 --- /dev/null +++ b/docs/user_guide/hamilton/heater_shaker/hello-world.ipynb @@ -0,0 +1,320 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5r7wvdhlz74", + "metadata": {}, + "source": [ + "# Hamilton Heater Shaker\n", + "\n", + "The Hamilton Heater Shaker is a combined temperature-control and orbital-shaking device for microplates. It supports:\n", + "\n", + "- [Shaking](../../capabilities/shaking) (orbital, 20--2000 rpm)\n", + "- [Temperature control](../../capabilities/temperature-control) (heating only, RT+5 °C to 105 °C)\n", + "- Plate locking\n", + "\n", + "Active cooling is **not** supported.\n", + "\n", + "| Variant (Cat. No.) | Shaking Orbit | Shaking Speed | Max. Loading |\n", + "|---|---|---|---|\n", + "| 199027 | 1.5 mm | 100--1800 rpm | -- |\n", + "| 199033 | 2.0 mm | 100--2500 rpm | -- |\n", + "| 199034 | 3.0 mm | 100--2400 rpm | 500 g |\n", + "\n", + "All variants share the same backend and are represented by the `HamiltonHeaterShaker` class.\n", + "\n", + "The Heater Shaker can be controlled through two drivers:\n", + "\n", + "- **HamiltonHeaterShakerBox** -- a USB control box supporting up to 8 heater shakers. One heater shaker is connected via USB to the host computer and acts as a gateway.\n", + "- **Hamilton STAR** -- the heater shaker plugs directly into one of the two RS232 ports on the STAR liquid handler. The STAR's driver is used directly." + ] + }, + { + "cell_type": "markdown", + "id": "6ld6ycgt6ts", + "metadata": {}, + "source": [ + "## Setup with the control box" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ccavi6ytak", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.heater_shaker import HamiltonHeaterShaker, HamiltonHeaterShakerBox\n", + "\n", + "box = HamiltonHeaterShakerBox()\n", + "await box.setup()\n", + "\n", + "hs = HamiltonHeaterShaker(name=\"hhs\", index=0, driver=box)\n", + "await hs.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "nbhdn47bfo7", + "metadata": {}, + "source": [ + "The `index` parameter identifies which heater shaker on the box you are addressing. Set `index=0` for the unit that is connected via USB to the host computer. Other units on the same box use `index=1`, `index=2`, etc. (requires setting the DIP switch on the bottom of each module)." + ] + }, + { + "cell_type": "markdown", + "id": "gs98qr2tt3f", + "metadata": {}, + "source": [ + "## Alternative: setup via a Hamilton STAR\n", + "\n", + "If the heater shaker is plugged directly into a STAR liquid handler, use the STAR's driver. The back RS232 port corresponds to `index=1` and the front port to `index=2`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4cell9jojxp", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.heater_shaker import HamiltonHeaterShaker\n", + "from pylabrobot.hamilton.liquid_handlers.star import STAR\n", + "\n", + "star = STAR()\n", + "await star.setup()\n", + "\n", + "hs = HamiltonHeaterShaker(name=\"hhs\", index=1, driver=star.driver)\n", + "await hs.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "90l43sny5ub", + "metadata": {}, + "source": [ + "When using the STAR, call `setup()` only once on the `STAR` device. Do not call `star.setup()` again if it is already set up." + ] + }, + { + "cell_type": "markdown", + "id": "oxasknrz3b", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "The heater shaker exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `hs.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "lyslqdvyznj", + "metadata": {}, + "outputs": [], + "source": [ + "await hs.tc.set_temperature(37.0)\n", + "await hs.tc.wait_for_temperature()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "g2wrzk77rxr", + "metadata": {}, + "outputs": [], + "source": [ + "current = await hs.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "markdown", + "id": "kix8fytc7wh", + "metadata": {}, + "source": [ + "The Hamilton Heater Shaker also has an edge temperature sensor, accessible via the {class}`~pylabrobot.hamilton.heater_shaker.backend.HamiltonHeaterShakerBackend`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7asdjajne", + "metadata": {}, + "outputs": [], + "source": [ + "edge = await hs.tc.backend.request_edge_temperature()\n", + "print(f\"Edge: {edge:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "qaorcsn1wn8", + "metadata": {}, + "outputs": [], + "source": [ + "await hs.tc.deactivate()" + ] + }, + { + "cell_type": "markdown", + "id": "4r40xq8kobw", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "The heater shaker exposes a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` on `hs.shaker`. For the full API, see [Shaking](../../capabilities/shaking)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6xxe3dz6tet", + "metadata": {}, + "outputs": [], + "source": [ + "await hs.shaker.lock_plate()\n", + "await hs.shaker.shake(speed=500)\n", + "# ... do other things ...\n", + "await hs.shaker.stop_shaking()\n", + "await hs.shaker.unlock_plate()" + ] + }, + { + "cell_type": "markdown", + "id": "q97oql7rho", + "metadata": {}, + "source": [ + "The {class}`~pylabrobot.hamilton.heater_shaker.backend.HamiltonHeaterShakerBackend` accepts `direction` (0 or 1) and `acceleration` (500--10000 rpm/s) parameters on `start_shaking`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "x2rm5rm6msk", + "metadata": {}, + "outputs": [], + "source": [ + "await hs.shaker.shake(speed=800, direction=0, acceleration=2000)\n", + "await hs.shaker.stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "id": "wu80g51rhpc", + "metadata": {}, + "source": [ + "## Using multiple heater shakers\n", + "\n", + "### Multiple heater shakers on one control box\n", + "\n", + "Each `HamiltonHeaterShaker` gets a different `index` but shares the same `HamiltonHeaterShakerBox` instance (one USB connection)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "lilfxtjcga", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.heater_shaker import HamiltonHeaterShaker, HamiltonHeaterShakerBox\n", + "\n", + "box = HamiltonHeaterShakerBox()\n", + "await box.setup()\n", + "\n", + "hs0 = HamiltonHeaterShaker(name=\"hhs0\", index=0, driver=box)\n", + "hs1 = HamiltonHeaterShaker(name=\"hhs1\", index=1, driver=box)\n", + "hs2 = HamiltonHeaterShaker(name=\"hhs2\", index=2, driver=box)\n", + "\n", + "for unit in [hs0, hs1, hs2]:\n", + " await unit.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "9om5bxy2sjq", + "metadata": {}, + "source": [ + "### Two heater shakers on a STAR\n", + "\n", + "The STAR has two RS232 ports. The back port is `index=1`, the front port is `index=2`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eo116gl6ria", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.heater_shaker import HamiltonHeaterShaker\n", + "from pylabrobot.hamilton.liquid_handlers.star import STAR\n", + "\n", + "star = STAR()\n", + "await star.setup()\n", + "\n", + "hs1 = HamiltonHeaterShaker(name=\"hhs1\", index=1, driver=star.driver)\n", + "hs2 = HamiltonHeaterShaker(name=\"hhs2\", index=2, driver=star.driver)\n", + "\n", + "for unit in [hs1, hs2]:\n", + " await unit.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "n6hmafh4gl", + "metadata": {}, + "source": [ + "## Deck assignment\n", + "\n", + "To use the heater shaker with the STAR's iSWAP or CoRe grippers (or to see it in the Visualizer), assign it to the deck. For example, using an `MFX_CAR_P3_SHAKER` carrier:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "iw2dt0ik8h", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources.hamilton.mfx_carriers import MFX_CAR_P3_SHAKER\n", + "\n", + "shaker_carrier = MFX_CAR_P3_SHAKER(name=\"shaker_carrier\", modules={0: hs2, 1: hs1})\n", + "star.deck.assign_child_resource(shaker_carrier, rails=5)" + ] + }, + { + "cell_type": "markdown", + "id": "er62g7g3omn", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cirrokko72g", + "metadata": {}, + "outputs": [], + "source": [ + "await hs.stop()" + ] + } + ], + "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/hamilton/hepa_fan/hello-world.ipynb b/docs/user_guide/hamilton/hepa_fan/hello-world.ipynb new file mode 100644 index 00000000000..d2bc5b0d335 --- /dev/null +++ b/docs/user_guide/hamilton/hepa_fan/hello-world.ipynb @@ -0,0 +1,117 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hamilton HEPA Fan\n", + "\n", + "The Hamilton HEPA Fan is an air filtration attachment for STAR and STARlet liquid handling workstations. It filters ambient air and supplies clean air to the deck interior, protecting samples from airborne contamination.\n", + "\n", + "| Detail | Value |\n", + "|---|---|\n", + "| PLR Name | `HamiltonHepaFan` |\n", + "| Communication | Serial (FTDI) / USB-A |\n", + "| VID:PID | `0856:ac11` |\n", + "| Capabilities | [Fan control](../../capabilities/fan-control) |\n", + "\n", + "**Physical setup:** Place the HEPA fan on top of the STAR(let) chassis. Connect the power cord (IEC C13) and a USB cable (USB-B at the fan, USB-A at the control PC). On older models, verify the voltage switch matches your mains voltage." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.only_fans import HamiltonHepaFan\n\nhhfan = HamiltonHepaFan(name=\"hepa_fan\")\nawait hhfan.setup()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you have multiple fans, pass `device_id` to select a specific FTDI device:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "hhfan = HamiltonHepaFan(name=\"hepa_fan\", device_id=\"ABC123\")\nawait hhfan.setup()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Fan control\n\nThe HEPA fan exposes a {class}`~pylabrobot.capabilities.fan_control.fan_control.Fan` capability on `hhfan.fan`. For the full API, see [Fan control](../../capabilities/fan-control)." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run at a given intensity (returns immediately):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await hhfan.fan.turn_on(intensity=100)" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await hhfan.fan.turn_off()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run for a fixed duration (blocks until done):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await hhfan.fan.turn_on(intensity=80, duration=30)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await hhfan.stop()" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/user_guide/hamilton/index.md b/docs/user_guide/hamilton/index.md new file mode 100644 index 00000000000..a85cdff1ce7 --- /dev/null +++ b/docs/user_guide/hamilton/index.md @@ -0,0 +1,10 @@ +# Hamilton + +```{toctree} +:maxdepth: 1 + +star/index +heater_cooler/hello-world +heater_shaker/hello-world +hepa_fan/hello-world +``` diff --git a/docs/user_guide/hamilton/star/96head.ipynb b/docs/user_guide/hamilton/star/96head.ipynb new file mode 100644 index 00000000000..b0b7917cec0 --- /dev/null +++ b/docs/user_guide/hamilton/star/96head.ipynb @@ -0,0 +1,470 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "hhxvx5md1r", + "metadata": {}, + "source": [ + "# Using the 96 head\n", + "\n", + "Some liquid handling robots have a 96 head, which can be used to pipette 96 samples at once. On the Hamilton STAR, the 96 head is an optional module. After {meth}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR.setup`, it is available as `star.head96` (or `None` if not installed).\n", + "\n", + "This notebook shows how to use the {class}`~pylabrobot.capabilities.liquid_handling.head96.Head96` capability to pick up tips, aspirate, dispense, and work with 384-well plate quadrants." + ] + }, + { + "cell_type": "markdown", + "id": "azovvt2iaus", + "metadata": {}, + "source": "## Setup\n\nImport {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STARLet` and create the device. After `setup()`, `star.head96` is a {class}`~pylabrobot.capabilities.liquid_handling.head96.Head96` instance if the hardware is installed." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "qpiepu5ha8", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star import STARLet\n\nstar = STARLet()\nawait star.setup()" + }, + { + "cell_type": "markdown", + "id": "tbj45iefog8", + "metadata": {}, + "source": [ + "## Creating the deck layout\n", + "\n", + "Assign a tip carrier with a 96-tip rack and a plate carrier with a 96-well plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3qfdhp0agk", + "metadata": {}, + "outputs": [], + "source": "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\ntip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\ntip_carrier[1] = tip_rack = hamilton_96_tiprack_50uL(name=\"tip_rack\")\nstar.deck.assign_child_resource(tip_carrier, rails=3)\n\nplt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\nplt_carrier[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nstar.deck.assign_child_resource(plt_carrier, rails=10)" + }, + { + "cell_type": "markdown", + "id": "jegesg507g", + "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 live on `star.head96` and take {class}`~pylabrobot.resources.tip_rack.TipRack`s and {class}`~pylabrobot.resources.plate.Plate`s as arguments, as opposed to individual `TipSpot`s and `Well`s used with `star.pip`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3lyj0n92lap", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.pick_up_tips(tip_rack)" + ] + }, + { + "cell_type": "markdown", + "id": "cn4mofgrwrq", + "metadata": {}, + "source": [ + "For aspirations and dispenses, a single volume is passed because all 96 channels move in unison.\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": null, + "id": "sot76da18n", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.aspirate(plate, volume=50)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jbvepi2u6tq", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.dispense(plate, volume=50)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ubjx15buaz", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.return_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "gogy6bqwfa", + "metadata": {}, + "source": [ + "## Quadrants\n", + "\n", + "96 heads can also be used to pipette quadrants of a 384-well plate. A 384-well plate is laid out as four interleaved 96-well quadrants. Use {meth}`~pylabrobot.resources.plate.Plate.get_quadrant` to select the wells for a given quadrant (1 through 4).\n", + "\n", + "![quadrants](img/96head/quadrants.png)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "icniks93cjp", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import BioRad_384_wellplate_50uL_Vb\n", + "\n", + "plt_carrier[1] = plate384 = BioRad_384_wellplate_50uL_Vb(name=\"plate384\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e49ujzyue9r", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.pick_up_tips(tip_rack)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sa7btm8n9rb", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.aspirate(plate384.get_quadrant(\"tl\"), volume=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rn42yky4oc", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.dispense(plate384.get_quadrant(\"bl\"), volume=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4i604cwipwe", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.aspirate(plate384.get_quadrant(\"tr\"), volume=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "x1h3zgjrqdo", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.dispense(plate384.get_quadrant(\"br\"), volume=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2owtro6jcr", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.return_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "dvq8prqq7j", + "metadata": {}, + "source": [ + "## Backend parameters\n", + "\n", + "For STAR-specific tuning, pass `backend_params` to any 96-head operation. The available parameter classes are:\n", + "\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.PickUpTips96Params`\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.DropTips96Params`\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.Aspirate96Params`\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.head96_backend.STARHead96Backend.Dispense96Params`\n", + "\n", + "For example, to use LLD:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "l26bh3mt66k", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.head96_backend import STARHead96Backend\n", + "\n", + "await star.head96.pick_up_tips(tip_rack)\n", + "await star.head96.aspirate(\n", + " plate,\n", + " volume=50,\n", + " backend_params=STARHead96Backend.Aspirate96Params(\n", + " use_lld=True,\n", + " )\n", + ")\n", + "await star.head96.dispense(plate, volume=50)\n", + "await star.head96.return_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "zntst23pcag", + "metadata": {}, + "source": [ + "## Stamping\n", + "\n", + "Stamping lets you aspirate from one plate and dispense to another in a single call. This is convenient for plate-to-plate transfers where the source and target have the same well layout." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sfawlquci3l", + "metadata": {}, + "outputs": [], + "source": [ + "target_plate = Cor_96_wellplate_360ul_Fb(name=\"target_plate\")\n", + "plt_carrier[2] = target_plate\n", + "\n", + "await star.head96.pick_up_tips(tip_rack)\n", + "await star.head96.stamp(plate, target_plate, volume=50)\n", + "await star.head96.return_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "ezmg8duvaww", + "metadata": {}, + "source": [ + "## Discarding tips\n", + "\n", + "Use `discard_tips()` to drop tips into the trash instead of returning them to the tip rack. The 96-head trash location on the deck is found automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "wm2yomwv4fs", + "metadata": {}, + "outputs": [], + "source": [ + "await star.head96.pick_up_tips(tip_rack)\n", + "await star.head96.discard_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "ssr1f7lbrw", + "metadata": {}, + "source": [ + "## Direct movement\n", + "\n", + "For low-level control, access the backend directly via `star.driver.head96`. This gives you absolute positioning, axis-specific moves, safety moves, position queries, and tip presence checks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sc3jxc24ug", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Coordinate\n", + "\n", + "await star.driver.head96.move_to_coordinate(Coordinate(x=200, y=100, z=300))" + ] + }, + { + "cell_type": "markdown", + "id": "33ma0o0qdjy", + "metadata": {}, + "source": [ + "Move individual axes:\n", + "\n", + "```{warning}\n", + "Direct movement is very powerful but also very dangerous. Always make sure you know exactly where the head will move to and that there are no obstacles in the way.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9i40sagf15v", + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.head96.move_y(y=200)\n", + "await star.driver.head96.move_z(z=300)" + ] + }, + { + "cell_type": "markdown", + "id": "b4jsacsp9l", + "metadata": {}, + "source": [ + "Move to the safe Z height:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "tb39zpe92yi", + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.head96.move_to_z_safety()" + ] + }, + { + "cell_type": "markdown", + "id": "34cbzy99a9f", + "metadata": {}, + "source": [ + "Query the current position and tip presence:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7dp9lkecc", + "metadata": {}, + "outputs": [], + "source": [ + "position = await star.driver.head96.request_position()\n", + "tips_present = await star.driver.head96.request_tip_presence()" + ] + }, + { + "cell_type": "markdown", + "id": "m65g8nnau8", + "metadata": {}, + "source": [ + "Park the 96-head:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "yvndkn27gf", + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.head96.park()" + ] + }, + { + "cell_type": "markdown", + "id": "w6va6zz36ys", + "metadata": {}, + "source": [ + "## Dispensing drive control\n", + "\n", + "The dispensing (plunger) drive can be controlled directly to move to absolute volume positions or query the current position in microliters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xoq004wagkn", + "metadata": {}, + "outputs": [], + "source": [ + "# Move plunger to an absolute volume position (in uL)\n", + "await star.driver.head96.dispensing_drive_move_to_position(position=100)\n", + "\n", + "# Query current plunger position in uL and mm\n", + "current_pos = await star.driver.head96.dispensing_drive_request_position_uL()\n", + "current_pos = await star.driver.head96.dispensing_drive_request_position_mm()" + ] + }, + { + "cell_type": "markdown", + "id": "gql7u970dj7", + "metadata": {}, + "source": [ + "## Trough support\n", + "\n", + "The 96 head can aspirate from a single trough container with all 96 channels simultaneously. Just pass the trough resource to `aspirate` the same way you would pass a plate." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5tdtiebqufw", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Trough\n", + "\n", + "trough = Trough(name=\"trough\", size_x=127.0, size_y=86.0, size_z=44.0, max_volume=300_000, material_z_thickness=1)\n", + "plt_carrier[3] = trough\n", + "\n", + "await star.head96.pick_up_tips(tip_rack)\n", + "await star.head96.aspirate(trough, volume=50)\n", + "await star.head96.dispense(plate, volume=50)\n", + "await star.head96.return_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "ly5lb9ugqih", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sgg6uh9ybrb", + "metadata": {}, + "outputs": [], + "source": [ + "await star.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.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/autoload_and_1d_barcode_reader.ipynb b/docs/user_guide/hamilton/star/autoload.ipynb similarity index 59% rename from docs/user_guide/00_liquid-handling/hamilton-star/autoload_and_1d_barcode_reader.ipynb rename to docs/user_guide/hamilton/star/autoload.ipynb index 6de99b35d9e..765a7fd72fb 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-star/autoload_and_1d_barcode_reader.ipynb +++ b/docs/user_guide/hamilton/star/autoload.ipynb @@ -4,11 +4,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Using the autoload & 1D barcode reader\n", + "# 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" + "|
  • 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
|" ] }, { @@ -24,37 +24,39 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Setup" + "## Setup\n", + "\n", + "Import {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR` and a deck class. The autoload subsystem is exposed as `star.driver.autoload` -- an {class}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload` instance (or `None` if the hardware is not installed)." ] }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star import STAR\nfrom 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\nstar = STAR()\nawait star.setup()\n\n# assign a tip rack carrier\ntip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\ntip_carrier[1] = tip_rack = hamilton_96_tiprack_50uL(name=\"tip_rack\")\nstar.deck.assign_child_resource(tip_carrier, rails=10)\n\n# assign a plate carrier\nplt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\nplt_carrier[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plt\")\nstar.deck.assign_child_resource(plt_carrier, rails=30)" + }, + { + "cell_type": "markdown", + "metadata": {}, "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", + "The new API methods on {class}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload` take a `carrier_end_rail` integer instead of a carrier object. Compute it from the carrier's rail position and its width in rails:\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", + "carrier_end_rail = carrier_start_rail + carrier_width_in_rails - 1\n", + "```\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" + "For a `TIP_CAR_480_A00` (6 rails wide) assigned at rails=10: `carrier_end_rail = 10 + 6 - 1 = 15`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tip_carrier_end_rail = 15 # rails=10, 6 rails wide -> 10 + 6 - 1 = 15\n", + "plt_carrier_end_rail = 35 # rails=30, 6 rails wide -> 30 + 6 - 1 = 35" ] }, { @@ -74,12 +76,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Querying autoload state" + "## Querying autoload state\n", + "\n", + "Check whether the autoload module is installed by inspecting `star.driver.autoload`:" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -88,18 +92,25 @@ "True" ] }, - "execution_count": 2, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "star.autoload_installed" + "star.driver.autoload is not None" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Query the current track of the autoload carrier handler with {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.request_track`:" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -108,18 +119,25 @@ "54" ] }, - "execution_count": 3, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.request_autoload_track()\n" + "await star.driver.autoload.request_track()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Query the autoload module type with {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.request_type`:" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -128,13 +146,13 @@ "'ML-STAR with 1D Barcode Scanner'" ] }, - "execution_count": 4, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.request_autoload_type()\n" + "await star.driver.autoload.request_type()" ] }, { @@ -145,12 +163,12 @@ "## 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:" + "This sensor can be used to scan the entire loading tray to identify whether there are any carriers currently on the loading tray with {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.request_presence_of_carriers_on_loading_tray`:" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -159,13 +177,13 @@ "[]" ] }, - "execution_count": 5, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.request_presence_of_carriers_on_loading_tray()\n" + "await star.driver.autoload.request_presence_of_carriers_on_loading_tray()" ] }, { @@ -176,12 +194,12 @@ "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. " + "Similarly, if you only want to investigate a single position this can be done with {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.request_presence_of_single_carrier_on_loading_tray`:" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -190,27 +208,27 @@ "False" ] }, - "execution_count": 6, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.request_presence_of_single_carrier_on_loading_tray(\n", - " track=44\n", - " )\n" + "await star.driver.autoload.request_presence_of_single_carrier_on_loading_tray(\n", + " track=44\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:" + "The counterpart to checking for carriers on the loading tray is checking for presence of carriers on the liquid handler's deck with {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.request_presence_of_carriers_on_deck`:" ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -219,22 +237,22 @@ "[15, 35]" ] }, - "execution_count": 7, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.request_presence_of_carriers_on_deck()\n" + "await star.driver.autoload.request_presence_of_carriers_on_deck()" ] }, { "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", + "Note that we have assigned the carriers based on the left-most track they are occupying but `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", @@ -242,75 +260,44 @@ "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." + "Together these methods enable capturing a full picture of the state of carriers on your liquid handler." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Moving autoload" + "## Moving autoload\n", + "\n", + "Use {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.move_to_safe_z_position`, {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.move_to_track`, and {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.park` to control the autoload position." ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0IVid0018er00/00'" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Always ensure the carrier handler is safely tucked away during movement.\n", - "await star.move_autoload_to_save_z_position()\n" + "await star.driver.autoload.move_to_safe_z_position()" ] }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'I0XPid0020er00'" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.move_autoload_to_track(30)\n" + "await star.driver.autoload.move_to_track(30)" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'I0XPid0022er00'" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.park_autoload()\n" + "await star.driver.autoload.park()" ] }, { @@ -318,32 +305,26 @@ "metadata": {}, "source": [ "---\n", - "## Basic Load & Unload" + "## Basic Load & Unload\n", + "\n", + "Use {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.unload_carrier` and {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.load_carrier` to move carriers between the deck and the loading tray. Both methods take `carrier_end_rail` instead of a carrier object." ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CRid0023er00/00'" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.unload_carrier(tip_carrier, park_autoload_after=False)\n" + "await star.driver.autoload.unload_carrier(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + " park_after=False,\n", + ")" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -353,13 +334,18 @@ " 'container_barcodes': None}" ] }, - "execution_count": 12, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.load_carrier(tip_carrier, park_autoload_after=False)\n" + "await star.driver.autoload.load_carrier(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + " carrier_barcode_reading=True,\n", + " barcode_reading=True,\n", + " park_autoload_after=False,\n", + ")" ] }, { @@ -373,13 +359,13 @@ "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", + "{class}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload` enables easy transfer between these 3 states:\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", + "`unload_carrier_after_barcode_scanning()` requires `load_carrier_from_tray_and_scan_carrier_barcode()` to precede it.\n", "```" ] }, @@ -389,7 +375,7 @@ "source": [ "## 1D Barcode reading\n", "\n", - "There are 2 main types of 1D barcodes one a classic STAR(let) deck:\n", + "There are 2 main types of 1D barcodes on 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", @@ -406,7 +392,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -422,13 +408,13 @@ " 'ANY 1D']" ] }, - "execution_count": 13, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "list(star.barcode_1d_symbology_dict.keys())\n" + "list(star.driver.autoload.barcode_1d_symbology_dict.keys())" ] }, { @@ -437,12 +423,12 @@ "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:" + "This is the default symbology:" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -451,28 +437,25 @@ "'Code 128 (Subset B and C)'" ] }, - "execution_count": 14, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "star._default_1d_symbology\n" + "star.driver.autoload._default_1d_symbology" ] }, { "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:" + "You can change the expected barcode symbology with {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.set_1d_barcode_type`:" ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -481,20 +464,20 @@ "'ISBT Standard'" ] }, - "execution_count": 15, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.set_1d_barcode_type(\"ISBT Standard\")\n", + "await star.driver.autoload.set_1d_barcode_type(\"ISBT Standard\")\n", "\n", - "star._default_1d_symbology\n" + "star.driver.autoload._default_1d_symbology" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -503,22 +486,22 @@ "'Code 128 (Subset B and C)'" ] }, - "execution_count": 16, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.set_1d_barcode_type(\"Code 128 (Subset B and C)\")\n", + "await star.driver.autoload.set_1d_barcode_type(\"Code 128 (Subset B and C)\")\n", "\n", - "star._default_1d_symbology\n" + "star.driver.autoload._default_1d_symbology" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Loading with with Barcode Reading\n", + "### Loading 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:" @@ -526,46 +509,38 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CRid0029er00/00'" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.unload_carrier(tip_carrier, park_autoload_after=False)\n" + "await star.driver.autoload.unload_carrier(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + " park_after=False,\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "...and this time activate reading both (1) the carrier barcord and (2) the container barcode:" + "...and this time activate reading both (1) the carrier barcode and (2) the container barcodes:" ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "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" + "barcode_readings = await star.driver.autoload.load_carrier(\n", + " carrier_end_rail=tip_carrier_end_rail,\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", + ")" ] }, { @@ -577,7 +552,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -591,7 +566,7 @@ " 4: Barcode(data='18235938752776513151', symbology='Code 128 (Subset B and C)', position_on_resource='right')}}" ] }, - "execution_count": 19, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -614,7 +589,7 @@ "```{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", + "The z-height of the laser (during horizontal) 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", "```" @@ -630,34 +605,28 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CRid0035er00/00'" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.unload_carrier(tip_carrier, park_autoload_after=False)\n" + "await star.driver.autoload.unload_carrier(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + " park_after=False,\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "**Loading tray -> Autoload Belt (Carrier Barcode Reading) -> Loading Tray**" + "**Loading tray -> Autoload Belt (Carrier Barcode Reading) -> Loading Tray**\n", + "\n", + "Use {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.load_carrier_from_tray_and_scan_carrier_barcode` to scan the carrier barcode, then {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.unload_carrier_after_barcode_scanning` to return the carrier to the tray:" ] }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -666,38 +635,27 @@ "Barcode(data='08T0241707', symbology='Code 128 (Subset B and C)', position_on_resource='right')" ] }, - "execution_count": 21, + "execution_count": null, "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" + "await star.driver.autoload.load_carrier_from_tray_and_scan_carrier_barcode(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + " # barcode_position=4.3, # mm\n", + " # barcode_reading_window_width=38.0, # mm\n", + " # reading_speed=128.1, # mm/sec\n", + ")" ] }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CAid0037er00/00'" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.unload_carrier_after_carrier_barcode_scanning()" + "await star.driver.autoload.unload_carrier_after_barcode_scanning()" ] }, { @@ -706,12 +664,12 @@ "source": [ "**Loading tray -> Autoload Belt (Carrier Barcode Reading) -> Deck (Container Barcode Reading)**\n", "\n", - "-> same as simply `.load_carrier()` but split into separate components" + "This is equivalent to {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.load_carrier` but split into separate components:" ] }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -720,20 +678,20 @@ "Barcode(data='08T0241707', symbology='Code 128 (Subset B and C)', position_on_resource='right')" ] }, - "execution_count": 23, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await star.load_carrier_from_tray_and_scan_carrier_barcode(\n", - " tip_carrier\n", - " )" + "await star.driver.autoload.load_carrier_from_tray_and_scan_carrier_barcode(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + ")" ] }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -746,23 +704,23 @@ " 4: Barcode(data='18235938752776513151', symbology='Code 128 (Subset B and C)', position_on_resource='right')}" ] }, - "execution_count": 24, + "execution_count": null, "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 = await star.driver.autoload.load_carrier_from_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" ] }, @@ -770,21 +728,25 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**Deck -> Autoload Belt -> Deck (with container barcode reading only)**" + "**Deck -> Autoload Belt -> Deck (with container barcode reading only)**\n", + "\n", + "Use {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.take_carrier_out_to_belt` to move a carrier from the deck to the identification position, then {meth}`~pylabrobot.hamilton.liquid_handlers.star.autoload.STARAutoload.load_carrier_from_belt` to load it back with barcode reading:" ] }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "await star.take_carrier_out_to_autoload_belt(tip_carrier)\n" + "await star.driver.autoload.take_carrier_out_to_belt(\n", + " carrier_end_rail=tip_carrier_end_rail,\n", + ")" ] }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -797,57 +759,35 @@ " 4: Barcode(data='18235938752776513151', symbology='Code 128 (Subset B and C)', position_on_resource='right')}" ] }, - "execution_count": 26, + "execution_count": null, "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 = await star.driver.autoload.load_carrier_from_belt(\n", + " barcode_reading=True,\n", + " park_autoload_after=False,\n", + ")\n", "reading" ] }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0IVid0045er00/00'" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.move_autoload_to_save_z_position()" + "await star.driver.autoload.move_to_safe_z_position()" ] }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'I0XPid0047er00'" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "await star.park_autoload()" + "await star.driver.autoload.park()" ] }, { @@ -855,37 +795,22 @@ "metadata": {}, "source": [ "---\n", - "## Close Connection" + "## Teardown" ] }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "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", - "```" + "await star.stop()" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [] } ], "metadata": { "kernelspec": { - "display_name": "plr", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -897,11 +822,10 @@ "file_extension": ".py", "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" + "nbformat_minor": 4, + "version": "3.10.0" } }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/docs/user_guide/hamilton/star/core-grippers.ipynb b/docs/user_guide/hamilton/star/core-grippers.ipynb new file mode 100644 index 00000000000..ced20d7ae25 --- /dev/null +++ b/docs/user_guide/hamilton/star/core-grippers.ipynb @@ -0,0 +1,160 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Co-Re Grippers\n", + "\n", + "The Co-Re grippers mount on pipetting channels and allow for moving labware around the deck. This tutorial shows how to use them with the {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR` device." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Gripper types\n", + "\n", + "There are two different types of Co-Re grippers:\n", + "\n", + "![Co-Re gripper types](img/core-grippers/core-gripper-types.jpg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Import {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR` and create the device. The deck is available as `star.deck`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star import STAR\n", + "\n", + "star = STAR()\n", + "await star.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set up a plate carrier and plate for the examples below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import PLT_CAR_L5AC_A00, CellTreat_96_wellplate_350ul_Ub\n", + "\n", + "plate_carrier = PLT_CAR_L5AC_A00(name=\"plate_carrier\")\n", + "plate_carrier[0] = plate = CellTreat_96_wellplate_350ul_Ub(name=\"plate\")\n", + "star.deck.assign_child_resource(plate_carrier, rails=14)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Moving resources\n", + "\n", + "Use {meth}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR.core_grippers` as an async context manager. It picks up the gripper tools when entering and returns them when exiting. The yielded {class}`~pylabrobot.capabilities.arms.arm.GripperArm` has two APIs:\n", + "\n", + "- `move_resource`: a single call that picks up a resource and drops it at a destination.\n", + "- `pick_up_resource` and `drop_resource`: two separate calls for more control over timing." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `move_resource`\n", + "\n", + "This is the simplest way to move a plate. Specify `front_channel` to choose which pair of pipetting channels picks up the gripper tools (the back channel is `front_channel - 1`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "async with star.core_grippers(front_channel=7) as arm:\n", + " await arm.move_resource(plate, plate_carrier[1], pickup_distance_from_bottom=10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `pick_up_resource` and `drop_resource`\n", + "\n", + "For more control over the pick-up and drop actions, use them separately within the same context manager." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "async with star.core_grippers(front_channel=7) as arm:\n", + " await arm.pick_up_resource(plate, pickup_distance_from_bottom=10)\n", + " # ... do something between pick-up and drop ...\n", + " await arm.drop_resource(plate_carrier[0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The gripper tools are automatically returned to their parking position when the `async with` block exits, so you never need to manage that manually." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.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", + "nbformat_minor": 4, + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} 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/foil.ipynb b/docs/user_guide/hamilton/star/foil.ipynb similarity index 52% rename from docs/user_guide/00_liquid-handling/hamilton-star/foil.ipynb rename to docs/user_guide/hamilton/star/foil.ipynb index 75d0568dc30..3e68633243c 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-star/foil.ipynb +++ b/docs/user_guide/hamilton/star/foil.ipynb @@ -2,11 +2,12 @@ "cells": [ { "cell_type": "markdown", + "id": "k0gicqfsz98", "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", + "The {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend` 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." @@ -14,6 +15,7 @@ }, { "cell_type": "markdown", + "id": "eiitlohc6b6", "metadata": {}, "source": [ "## Example setup\n", @@ -26,90 +28,63 @@ { "cell_type": "code", "execution_count": null, + "id": "v4315rh5yh", "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)" - ] + "source": "from pylabrobot.hamilton.liquid_handlers.star import STARLet\nfrom pylabrobot.resources import (\n TIP_CAR_480_A00,\n PLT_CAR_L5AC_A00,\n hamilton_96_tiprack_1000uL_filter,\n AGenBio_4_troughplate_75000uL_Vb,\n)\n\nstar = STARLet()\nawait star.setup()\n\n# assign a tip rack\ntip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\ntip_carrier[0] = tip_rack = hamilton_96_tiprack_1000uL_filter(name=\"tip_rack\")\nstar.deck.assign_child_resource(tip_carrier, rails=3)\n\n# assign a plate\nplt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\nplt_carrier[0] = plate = AGenBio_4_troughplate_75000uL_Vb(name=\"plate\")\nstar.deck.assign_child_resource(plt_carrier, rails=10)" }, { "cell_type": "markdown", + "id": "3ljyh53w3k5", "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", + "It is important to break the foil before aspirating because tiny foil pieces can get 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, + "execution_count": 2, + "id": "ngjlfbvrl5i", "metadata": {}, "outputs": [], "source": [ "well = plate.get_well(\"A1\")\n", - "await lh.pick_up_tips(tip_rack[\"A1:H1\"])" + "await star.pip.pick_up_tips(tip_rack[\"A1:H1\"])" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, + "id": "vili8gjgpd", "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", - ")" - ] + "source": "aspiration_channels = [1, 2, 3, 4, 5, 6]\nhold_down_channels = [0, 7]\nawait star.driver.pip.pierce_foil(\n wells=[well],\n piercing_channels=aspiration_channels,\n hold_down_channels=hold_down_channels,\n move_inwards=4,\n deck=star.deck,\n one_by_one=False,\n)" }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, + "id": "euyszrd13vu", "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.pip.drop_tips(tip_rack[\"A1:H1\"])" ] }, { "cell_type": "markdown", + "id": "37hnwq9yeps", "metadata": {}, "source": [ - "![gif of piercing foil](./img/pierce_foil.gif)" + "![gif of piercing foil](img/pierce_foil.gif)" ] }, { "cell_type": "markdown", + "id": "ds9z9mpfgzq", "metadata": {}, "source": [ "## Holding the plate down\n", @@ -121,55 +96,53 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 6, + "id": "9d22b47e", "metadata": {}, "outputs": [], "source": [ - "await lh.pick_up_tips(tip_rack[\"A2:H2\"])" + "await star.pip.return_tips()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, + "id": "xoyrpbrjroj", "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", - ")" + "await star.pip.pick_up_tips(tip_rack[\"A2:H2\"])" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, + "id": "kv0alzjiexh", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend\n\nnum_channels = len(aspiration_channels)\nawait star.pip.aspirate(\n [well] * num_channels,\n vols=[100] * num_channels,\n use_channels=aspiration_channels,\n backend_params=STARPIPBackend.AspirateParams(\n min_z_endpos=well.get_location_wrt(star.deck, z=\"cavity_bottom\").z,\n surface_following_distance=[0] * num_channels,\n pull_out_distance_transport_air=[0] * num_channels,\n ),\n)\n\nawait star.driver.pip.step_off_foil(\n well,\n front_channel=7,\n back_channel=0,\n deck=star.deck,\n move_inwards=5,\n)" + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "d93irink69o", "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.pip.drop_tips(tip_rack[\"A2:H2\"])" ] }, { "cell_type": "markdown", + "id": "uoeovvq1v6a", "metadata": {}, "source": [ - "![gif of holding down foil](./img/step_off_foil.gif)" + "![gif of holding down foil](img/step_off_foil.gif)" ] }, { "cell_type": "markdown", + "id": "ngm7ibrmn4e", "metadata": {}, "source": [ "---\n", @@ -198,5 +171,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 -} + "nbformat_minor": 5 +} \ No newline at end of file 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..9d9ae1bc383 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,53 @@ 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 +from pylabrobot.capabilities.arms.standard import GripDirection + +await star.driver.iswap.rotate( + rotation_drive=iSWAPBackend.RotationDriveOrientation.RIGHT, + grip_direction=GripDirection.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=GripDirection.BACK, ) ``` @@ -103,7 +105,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/hamilton/star/hello-world.ipynb b/docs/user_guide/hamilton/star/hello-world.ipynb new file mode 100644 index 00000000000..4d333580d19 --- /dev/null +++ b/docs/user_guide/hamilton/star/hello-world.ipynb @@ -0,0 +1,321 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hamilton STAR\n", + "\n", + "The Hamilton STAR(let) is a liquid handler with independent pipetting channels, an optional 96-head, and an optional iSWAP plate transport arm.\n", + "\n", + "In this notebook, you will learn how to use PyLabRobot to set up the STAR, pick up tips, and move liquid between wells.\n", + "\n", + "**Prerequisites:**\n", + "\n", + "- Installed PyLabRobot with USB support: `pip install pylabrobot[usb]`\n", + "- Platform-specific driver setup (libusb on Mac, Zadig on Windows) — see [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": "## Setup\n\nImport {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STARLet`. `STARLet` is the device that owns the driver and exposes capabilities like `pip` (independent channels), `head96` (96-head), and `iswap` (plate transport arm)." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91e0b4e4", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star import STARLet\n\nstar = STARLet()\nawait star.setup()" + }, + { + "cell_type": "markdown", + "id": "3f7ec1fb", + "metadata": {}, + "source": [ + "After `setup()`, the driver discovers installed hardware automatically. `star.pip` is always available. `star.head96` and `star.iswap` are `None` if the corresponding hardware is not installed." + ] + }, + { + "cell_type": "markdown", + "id": "e6d63e36", + "metadata": {}, + "source": [ + "## Creating the deck layout\n", + "\n", + "Define the physical deck layout by assigning carriers with tip racks and plates. This tutorial uses:\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` 96-well plate\n", + "- {class}`~pylabrobot.resources.hamilton.tip_racks.hamilton_96_tiprack_1000uL_filter` tip rack" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c34267a4", + "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\ntip_car = TIP_CAR_480_A00(name=\"tip carrier\")\ntip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\nstar.deck.assign_child_resource(tip_car, rails=3)\n\nplt_car = PLT_CAR_L5AC_A00(name=\"plate carrier\")\nplt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\nstar.deck.assign_child_resource(plt_car, rails=15)" + }, + { + "cell_type": "markdown", + "id": "ly91jaw5s", + "metadata": {}, + "source": [ + "Let's look at a summary of the deck layout using {meth}`~pylabrobot.resources.deck.Deck.summary`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "p5btkl9lf8", + "metadata": {}, + "outputs": [], + "source": "print(star.deck.summary())" + }, + { + "cell_type": "markdown", + "id": "0514e9b4", + "metadata": {}, + "source": [ + "## Picking up tips\n", + "\n", + "Use `star.pip` — the independent-channel pipetting capability — to pick up tips. Indexing a tip rack with `[\"A1:C1\"]` returns the first three tip spots in column 1." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a38d7fc4", + "metadata": {}, + "outputs": [], + "source": "tiprack = star.deck.get_resource(\"tips_01\")\nawait star.pip.pick_up_tips(tiprack[\"A1:C1\"])" + }, + { + "cell_type": "markdown", + "id": "5710a9dd", + "metadata": {}, + "source": [ + "## Aspirating and dispensing\n", + "\n", + "Aspirate from wells `A1:C1` and dispense to `D1:F1`, using channels 0, 1, and 2." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a9a152b", + "metadata": {}, + "outputs": [], + "source": "plate = star.deck.get_resource(\"plate_01\")\nawait star.pip.aspirate(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f41dc519", + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.dispense(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])" + ] + }, + { + "cell_type": "markdown", + "id": "e230f31a", + "metadata": {}, + "source": [ + "Move the liquid back:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "18221b52", + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.aspirate(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])\n", + "await star.pip.dispense(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" + ] + }, + { + "cell_type": "markdown", + "id": "cfa34192", + "metadata": {}, + "source": [ + "## Dropping tips\n", + "\n", + "Return tips to their original positions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9863494d", + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.drop_tips(tiprack[\"A1:C1\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c6e69c7", + "metadata": {}, + "outputs": [], + "source": [ + "await star.stop()\n", + "await star.setup()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "70af81f7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'C0RTid0031er00/00rt0 0 0 0 0 0 0 0 0 0 0 0'" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await star.driver.send_command(\"C0\", \"RT\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7no9m28m7jb", + "metadata": {}, + "outputs": [], + "source": "import asyncio\n\nchannels = star.driver.pip.channels\nresults = await asyncio.gather(*[\n channels[i].request_probe_z_position() for i in range(3)\n])\nresults" + }, + { + "cell_type": "markdown", + "id": "206b41a6", + "metadata": {}, + "source": [ + "## Backend parameters\n", + "\n", + "For STAR-specific tuning, pass `backend_params` to any operation. The available parameter classes are:\n", + "\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.PickUpTipsParams`\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.DropTipsParams`\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.AspirateParams`\n", + "- {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.DispenseParams`\n", + "\n", + "For example, to use liquid level detection during aspiration:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "23048513", + "metadata": {}, + "outputs": [ + { + "ename": "ChannelizedError", + "evalue": "ChannelizedError(errors={0: TooLittleLiquidError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)'), 1: TooLittleLiquidError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)'), 2: TooLittleLiquidError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)')}, raw_response=C0ASid0029er99/00 P106/70 P206/70 P306/70)", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mSTARFirmwareError\u001b[0m Traceback (most recent call last)", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py:769\u001b[0m, in \u001b[0;36mSTARPIPBackend.aspirate\u001b[0;34m(self, ops, use_channels, backend_params)\u001b[0m\n\u001b[1;32m 768\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m--> 769\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mdriver\u001b[38;5;241m.\u001b[39msend_command(\n\u001b[1;32m 770\u001b[0m module\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mC0\u001b[39m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m 771\u001b[0m command\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mAS\u001b[39m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m 772\u001b[0m tip_pattern\u001b[38;5;241m=\u001b[39mchannels_involved,\n\u001b[1;32m 773\u001b[0m read_timeout\u001b[38;5;241m=\u001b[39m\u001b[38;5;28mmax\u001b[39m(\u001b[38;5;241m300\u001b[39m, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mdriver\u001b[38;5;241m.\u001b[39mread_timeout),\n\u001b[1;32m 774\u001b[0m at\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mat\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m01\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m at \u001b[38;5;129;01min\u001b[39;00m aspiration_types],\n\u001b[1;32m 775\u001b[0m tm\u001b[38;5;241m=\u001b[39mchannels_involved,\n\u001b[1;32m 776\u001b[0m xp\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mxp\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m05\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m xp \u001b[38;5;129;01min\u001b[39;00m x_positions],\n\u001b[1;32m 777\u001b[0m yp\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00myp\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m yp \u001b[38;5;129;01min\u001b[39;00m y_positions],\n\u001b[1;32m 778\u001b[0m th\u001b[38;5;241m=\u001b[39m\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mminimum_traverse_height_at_beginning_of_a_command\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m 779\u001b[0m te\u001b[38;5;241m=\u001b[39m\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmin_z_endpos\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m 780\u001b[0m lp\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(lsh\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m lsh \u001b[38;5;129;01min\u001b[39;00m lld_search_height],\n\u001b[1;32m 781\u001b[0m ch\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(cd\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m03\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m cd \u001b[38;5;129;01min\u001b[39;00m clot_detection_height],\n\u001b[1;32m 782\u001b[0m zl\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(ls\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m ls \u001b[38;5;129;01min\u001b[39;00m liquid_surfaces_no_lld],\n\u001b[1;32m 783\u001b[0m po\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(po\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m po \u001b[38;5;129;01min\u001b[39;00m pull_out_distance_transport_air],\n\u001b[1;32m 784\u001b[0m zu\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(sh\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m sh \u001b[38;5;129;01min\u001b[39;00m second_section_height],\n\u001b[1;32m 785\u001b[0m zr\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(sr\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m05\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m sr \u001b[38;5;129;01min\u001b[39;00m second_section_ratio],\n\u001b[1;32m 786\u001b[0m zx\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(mh\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m mh \u001b[38;5;129;01min\u001b[39;00m minimum_height],\n\u001b[1;32m 787\u001b[0m ip\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(id_\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m id_ \u001b[38;5;129;01min\u001b[39;00m immersion_depth],\n\u001b[1;32m 788\u001b[0m it\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00midd\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m idd \u001b[38;5;129;01min\u001b[39;00m immersion_depth_direction],\n\u001b[1;32m 789\u001b[0m fp\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(sfd\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m sfd \u001b[38;5;129;01min\u001b[39;00m surface_following_distance],\n\u001b[1;32m 790\u001b[0m av\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(vol\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m05\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m vol \u001b[38;5;129;01min\u001b[39;00m volumes],\n\u001b[1;32m 791\u001b[0m as_\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(fr\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m fr \u001b[38;5;129;01min\u001b[39;00m flow_rates],\n\u001b[1;32m 792\u001b[0m ta\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(tav\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m03\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m tav \u001b[38;5;129;01min\u001b[39;00m transport_air_volume],\n\u001b[1;32m 793\u001b[0m ba\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(boa\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m boa \u001b[38;5;129;01min\u001b[39;00m blow_out_air_volumes],\n\u001b[1;32m 794\u001b[0m oa\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(pwv\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m03\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m pwv \u001b[38;5;129;01min\u001b[39;00m pre_wetting_volume],\n\u001b[1;32m 795\u001b[0m lm\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmode\u001b[38;5;241m.\u001b[39mvalue\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m mode \u001b[38;5;129;01min\u001b[39;00m lld_mode],\n\u001b[1;32m 796\u001b[0m ll\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00ms\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m s \u001b[38;5;129;01min\u001b[39;00m gamma_lld_sensitivity],\n\u001b[1;32m 797\u001b[0m lv\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00ms\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m s \u001b[38;5;129;01min\u001b[39;00m dp_lld_sensitivity],\n\u001b[1;32m 798\u001b[0m zo\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(ap\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m03\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m ap \u001b[38;5;129;01min\u001b[39;00m aspirate_position_above_z_touch_off],\n\u001b[1;32m 799\u001b[0m ld\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(dh\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m02\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m dh \u001b[38;5;129;01min\u001b[39;00m detection_height_difference_for_dual_lld],\n\u001b[1;32m 800\u001b[0m de\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(ss\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m ss \u001b[38;5;129;01min\u001b[39;00m swap_speed],\n\u001b[1;32m 801\u001b[0m wt\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(st\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m02\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m st \u001b[38;5;129;01min\u001b[39;00m settling_time],\n\u001b[1;32m 802\u001b[0m mv\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(v\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m05\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m v \u001b[38;5;129;01min\u001b[39;00m mix_volume],\n\u001b[1;32m 803\u001b[0m mc\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mc\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m02\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m c \u001b[38;5;129;01min\u001b[39;00m mix_cycles],\n\u001b[1;32m 804\u001b[0m mp\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(p\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m03\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m p \u001b[38;5;129;01min\u001b[39;00m mix_position_from_liquid_surface],\n\u001b[1;32m 805\u001b[0m ms\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(s\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m s \u001b[38;5;129;01min\u001b[39;00m mix_speed],\n\u001b[1;32m 806\u001b[0m mh\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(d\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m d \u001b[38;5;129;01min\u001b[39;00m mix_surface_following_distance],\n\u001b[1;32m 807\u001b[0m gi\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mi\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m03\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m i \u001b[38;5;129;01min\u001b[39;00m limit_curve_index],\n\u001b[1;32m 808\u001b[0m gj\u001b[38;5;241m=\u001b[39mbackend_params\u001b[38;5;241m.\u001b[39mtadm_algorithm,\n\u001b[1;32m 809\u001b[0m gk\u001b[38;5;241m=\u001b[39mbackend_params\u001b[38;5;241m.\u001b[39mrecording_mode,\n\u001b[1;32m 810\u001b[0m lk\u001b[38;5;241m=\u001b[39m[\u001b[38;5;241m1\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m x \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;241m0\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m x \u001b[38;5;129;01min\u001b[39;00m _fill(backend_params\u001b[38;5;241m.\u001b[39muse_2nd_section_aspiration, [\u001b[38;5;28;01mFalse\u001b[39;00m] \u001b[38;5;241m*\u001b[39m n)],\n\u001b[1;32m 811\u001b[0m ik\u001b[38;5;241m=\u001b[39m[\n\u001b[1;32m 812\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(x\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 813\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m x \u001b[38;5;129;01min\u001b[39;00m _fill(backend_params\u001b[38;5;241m.\u001b[39mretract_height_over_2nd_section_to_empty_tip, [\u001b[38;5;241m0.0\u001b[39m] \u001b[38;5;241m*\u001b[39m n)\n\u001b[1;32m 814\u001b[0m ],\n\u001b[1;32m 815\u001b[0m sd\u001b[38;5;241m=\u001b[39m[\n\u001b[1;32m 816\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(x\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 817\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m x \u001b[38;5;129;01min\u001b[39;00m _fill(backend_params\u001b[38;5;241m.\u001b[39mdispensation_speed_during_emptying_tip, [\u001b[38;5;241m50.0\u001b[39m] \u001b[38;5;241m*\u001b[39m n)\n\u001b[1;32m 818\u001b[0m ],\n\u001b[1;32m 819\u001b[0m se\u001b[38;5;241m=\u001b[39m[\n\u001b[1;32m 820\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(x\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 821\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m x \u001b[38;5;129;01min\u001b[39;00m _fill(backend_params\u001b[38;5;241m.\u001b[39mdosing_drive_speed_during_2nd_section_search, [\u001b[38;5;241m50.0\u001b[39m] \u001b[38;5;241m*\u001b[39m n)\n\u001b[1;32m 822\u001b[0m ],\n\u001b[1;32m 823\u001b[0m sz\u001b[38;5;241m=\u001b[39m[\n\u001b[1;32m 824\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(x\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 825\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m x \u001b[38;5;129;01min\u001b[39;00m _fill(backend_params\u001b[38;5;241m.\u001b[39mz_drive_speed_during_2nd_section_search, [\u001b[38;5;241m30.0\u001b[39m] \u001b[38;5;241m*\u001b[39m n)\n\u001b[1;32m 826\u001b[0m ],\n\u001b[1;32m 827\u001b[0m io\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mround\u001b[39m(x\u001b[38;5;250m \u001b[39m\u001b[38;5;241m*\u001b[39m\u001b[38;5;250m \u001b[39m\u001b[38;5;241m10\u001b[39m)\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m04\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m x \u001b[38;5;129;01min\u001b[39;00m _fill(backend_params\u001b[38;5;241m.\u001b[39mcup_upper_edge, [\u001b[38;5;241m0.0\u001b[39m] \u001b[38;5;241m*\u001b[39m n)],\n\u001b[1;32m 828\u001b[0m )\n\u001b[1;32m 829\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m STARFirmwareError \u001b[38;5;28;01mas\u001b[39;00m e:\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/hamilton/liquid_handlers/base.py:263\u001b[0m, in \u001b[0;36mHamiltonLiquidHandler.send_command\u001b[0;34m(self, module, command, auto_id, tip_pattern, write_timeout, read_timeout, wait, fmt, **kwargs)\u001b[0m\n\u001b[1;32m 256\u001b[0m cmd, id_ \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_assemble_command(\n\u001b[1;32m 257\u001b[0m module\u001b[38;5;241m=\u001b[39mmodule,\n\u001b[1;32m 258\u001b[0m command\u001b[38;5;241m=\u001b[39mcommand,\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 261\u001b[0m \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs,\n\u001b[1;32m 262\u001b[0m )\n\u001b[0;32m--> 263\u001b[0m resp \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_write_and_read_command(\n\u001b[1;32m 264\u001b[0m id_\u001b[38;5;241m=\u001b[39mid_,\n\u001b[1;32m 265\u001b[0m cmd\u001b[38;5;241m=\u001b[39mcmd,\n\u001b[1;32m 266\u001b[0m write_timeout\u001b[38;5;241m=\u001b[39mwrite_timeout,\n\u001b[1;32m 267\u001b[0m read_timeout\u001b[38;5;241m=\u001b[39mread_timeout,\n\u001b[1;32m 268\u001b[0m wait\u001b[38;5;241m=\u001b[39mwait,\n\u001b[1;32m 269\u001b[0m )\n\u001b[1;32m 270\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m resp \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mand\u001b[39;00m fmt \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/hamilton/liquid_handlers/base.py:295\u001b[0m, in \u001b[0;36mHamiltonLiquidHandler._write_and_read_command\u001b[0;34m(self, id_, cmd, write_timeout, read_timeout, wait)\u001b[0m\n\u001b[1;32m 294\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_start_reading(id_, loop, fut, cmd, read_timeout)\n\u001b[0;32m--> 295\u001b[0m result \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m fut\n\u001b[1;32m 296\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m result\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/hamilton/liquid_handlers/base.py:385\u001b[0m, in \u001b[0;36mHamiltonLiquidHandler._continuously_read\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 384\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m--> 385\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mcheck_fw_string_error\u001b[49m\u001b[43m(\u001b[49m\u001b[43mresp\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 386\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/hamilton/liquid_handlers/star/driver.py:220\u001b[0m, in \u001b[0;36mSTARDriver.check_fw_string_error\u001b[0;34m(self, resp)\u001b[0m\n\u001b[1;32m 219\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(errors_dict) \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m:\n\u001b[0;32m--> 220\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m star_firmware_string_to_error(error_code_dict\u001b[38;5;241m=\u001b[39merrors_dict, raw_response\u001b[38;5;241m=\u001b[39mresp)\n", + "\u001b[0;31mSTARFirmwareError\u001b[0m: {'Pipetting channel 1': TipTooLittleVolumeError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)'), 'Pipetting channel 2': TipTooLittleVolumeError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)'), 'Pipetting channel 3': TipTooLittleVolumeError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)')}, C0ASid0029er99/00 P106/70 P206/70 P306/70", + "\nThe above exception was the direct cause of the following exception:\n", + "\u001b[0;31mChannelizedError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[11], line 5\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21;01mpylabrobot\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mhamilton\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mliquid_handlers\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mstar\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mpip_backend\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m STARPIPBackend, LLDMode\n\u001b[1;32m 3\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m star\u001b[38;5;241m.\u001b[39mpip\u001b[38;5;241m.\u001b[39mpick_up_tips(tiprack[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mD1:F1\u001b[39m\u001b[38;5;124m\"\u001b[39m])\n\u001b[0;32m----> 5\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m star\u001b[38;5;241m.\u001b[39mpip\u001b[38;5;241m.\u001b[39maspirate(\n\u001b[1;32m 6\u001b[0m plate[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mA1:C1\u001b[39m\u001b[38;5;124m\"\u001b[39m],\n\u001b[1;32m 7\u001b[0m vols\u001b[38;5;241m=\u001b[39m[\u001b[38;5;241m100.0\u001b[39m, \u001b[38;5;241m100.0\u001b[39m, \u001b[38;5;241m100.0\u001b[39m],\n\u001b[1;32m 8\u001b[0m backend_params\u001b[38;5;241m=\u001b[39mSTARPIPBackend\u001b[38;5;241m.\u001b[39mAspirateParams(\n\u001b[1;32m 9\u001b[0m lld_mode\u001b[38;5;241m=\u001b[39m[LLDMode\u001b[38;5;241m.\u001b[39mGAMMA, LLDMode\u001b[38;5;241m.\u001b[39mGAMMA, LLDMode\u001b[38;5;241m.\u001b[39mGAMMA],\n\u001b[1;32m 10\u001b[0m ),\n\u001b[1;32m 11\u001b[0m )\n\u001b[1;32m 12\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m star\u001b[38;5;241m.\u001b[39mpip\u001b[38;5;241m.\u001b[39mdispense(plate[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mD1:F1\u001b[39m\u001b[38;5;124m\"\u001b[39m], vols\u001b[38;5;241m=\u001b[39m[\u001b[38;5;241m100.0\u001b[39m, \u001b[38;5;241m100.0\u001b[39m, \u001b[38;5;241m100.0\u001b[39m])\n\u001b[1;32m 14\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m star\u001b[38;5;241m.\u001b[39mpip\u001b[38;5;241m.\u001b[39mdrop_tips(tiprack[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mD1:F1\u001b[39m\u001b[38;5;124m\"\u001b[39m])\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/capabilities/capability.py:45\u001b[0m, in \u001b[0;36mneed_capability_ready..wrapper\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 43\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msetup_finished:\n\u001b[1;32m 44\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[0;32m---> 45\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/capabilities/liquid_handling/pip.py:547\u001b[0m, in \u001b[0;36mPIP.aspirate\u001b[0;34m(self, resources, vols, use_channels, flow_rates, offsets, liquid_height, blow_out_air_volume, spread, mix, backend_params)\u001b[0m\n\u001b[1;32m 544\u001b[0m (tip_volume_tracker\u001b[38;5;241m.\u001b[39mcommit \u001b[38;5;28;01mif\u001b[39;00m success \u001b[38;5;28;01melse\u001b[39;00m tip_volume_tracker\u001b[38;5;241m.\u001b[39mrollback)()\n\u001b[1;32m 546\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m error \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m--> 547\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m error\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/capabilities/liquid_handling/pip.py:527\u001b[0m, in \u001b[0;36mPIP.aspirate\u001b[0;34m(self, resources, vols, use_channels, flow_rates, offsets, liquid_height, blow_out_air_volume, spread, mix, backend_params)\u001b[0m\n\u001b[1;32m 525\u001b[0m error: Optional[\u001b[38;5;167;01mException\u001b[39;00m] \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[1;32m 526\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m--> 527\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39maspirate(\n\u001b[1;32m 528\u001b[0m ops\u001b[38;5;241m=\u001b[39maspirations, use_channels\u001b[38;5;241m=\u001b[39muse_channels, backend_params\u001b[38;5;241m=\u001b[39mbackend_params\n\u001b[1;32m 529\u001b[0m )\n\u001b[1;32m 530\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[1;32m 531\u001b[0m error \u001b[38;5;241m=\u001b[39m e\n", + "File \u001b[0;32m~/retro/automation/pylabrobot/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py:831\u001b[0m, in \u001b[0;36mSTARPIPBackend.aspirate\u001b[0;34m(self, ops, use_channels, backend_params)\u001b[0m\n\u001b[1;32m 829\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m STARFirmwareError \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[1;32m 830\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m plr_e \u001b[38;5;241m:=\u001b[39m convert_star_firmware_error_to_plr_error(e):\n\u001b[0;32m--> 831\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m plr_e \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21;01me\u001b[39;00m\n\u001b[1;32m 832\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m\n", + "\u001b[0;31mChannelizedError\u001b[0m: ChannelizedError(errors={0: TooLittleLiquidError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)'), 1: TooLittleLiquidError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)'), 2: TooLittleLiquidError('No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)')}, raw_response=C0ASid0029er99/00 P106/70 P206/70 P306/70)" + ] + } + ], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend, LLDMode\n", + "\n", + "await star.pip.pick_up_tips(tiprack[\"D1:F1\"])\n", + "\n", + "await star.pip.aspirate(\n", + " plate[\"A1:C1\"],\n", + " vols=[100.0, 100.0, 100.0],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " lld_mode=[LLDMode.GAMMA, LLDMode.GAMMA, LLDMode.GAMMA],\n", + " ),\n", + ")\n", + "await star.pip.dispense(plate[\"D1:F1\"], vols=[100.0, 100.0, 100.0])\n", + "\n", + "await star.pip.drop_tips(tiprack[\"D1:F1\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82d1d354", + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.drop_tips(tiprack[\"D1:F1\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.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.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file 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..7a6ea380b1c --- /dev/null +++ b/docs/user_guide/hamilton/star/index.md @@ -0,0 +1,19 @@ +# Hamilton STAR + +```{toctree} +:maxdepth: 1 + +hello-world +96head +iswap +core-grippers +autoload +lld +surface-following +foil +liquid-classes +y-probing +z-probing +debug +hardware/index +``` diff --git a/docs/user_guide/hamilton/star/iswap.ipynb b/docs/user_guide/hamilton/star/iswap.ipynb new file mode 100644 index 00000000000..ed5b6f42eca --- /dev/null +++ b/docs/user_guide/hamilton/star/iswap.ipynb @@ -0,0 +1,659 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# iSWAP Module\n", + "\n", + "The iSWAP is a plate transport gripper arm on the Hamilton STAR(let). After {meth}`~pylabrobot.hamilton.liquid_handlers.star.star._HamiltonSTAR.setup`, it is available as `star.iswap` — an {class}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:51:32,189 - pylabrobot.io.usb - INFO - Finding USB device...\n", + "2026-04-08 17:51:32,196 - pylabrobot.io.usb - INFO - Found USB device.\n", + "2026-04-08 17:51:32,197 - pylabrobot.io.usb - INFO - Found endpoints. \n", + "Write:\n", + " ENDPOINT 0x2: Bulk OUT ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x2 OUT\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0 \n", + "Read:\n", + " ENDPOINT 0x81: Bulk IN ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x81 IN\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0\n" + ] + } + ], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star import STARLet\n", + "\n", + "star = STARLet()\n", + "await star.setup()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "assert star.iswap is not None, \"iSWAP is not available on this robot\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Deck layout" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import PLT_CAR_L5AC_A00, Cor_96_wellplate_360ul_Fb\n", + "\n", + "plate_carrier = PLT_CAR_L5AC_A00(name=\"plate_carrier\")\n", + "plate_carrier[1] = plate = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\n", + "star.deck.assign_child_resource(plate_carrier, rails=15)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Moving resources\n", + "\n", + "### `move_resource`\n", + "\n", + "The simplest way to move a plate between locations. This picks up the resource, moves it, and drops it in one call.\n", + "\n", + "For fine-grained control over pickup and drop, pass `backend_params` with {class}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.PickUpParams` or {class}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.DropParams`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:51:45,926 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] pick up plate: x=482.9, y=210.2, z=192.3, direction=0 deg, width=127.8 mm\n", + "2026-04-08 17:51:57,374 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] release plate: x=482.9, y=306.2, z=192.3, direction=0 deg, width=127.8 mm\n" + ] + } + ], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend\n", + "\n", + "await star.iswap.move_resource(plate, plate_carrier[2])" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:52:02,893 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] pick up plate: x=482.9, y=306.2, z=192.3, direction=0 deg, width=127.8 mm\n", + "2026-04-08 17:52:07,809 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] release plate: x=482.9, y=210.2, z=192.3, direction=0 deg, width=127.8 mm\n" + ] + } + ], + "source": [ + "await star.iswap.move_resource(\n", + " plate, plate_carrier[1],\n", + " pickup_backend_params=iSWAPBackend.PickUpParams(grip_strength=8),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Grip direction\n", + "\n", + "By default, the iSWAP grips from the front. Use the `direction` parameter to change this.\n", + "\n", + "This is the direction from which the iSWAP gripper will grip the plate. So if the direction is `LEFT`, the iSWAP will grip the plate from the left side (when looking at the front of the robot). The plate will be to the right of the iSWAP when gripping it." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:52:13,325 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] pick up plate: x=482.9, y=210.2, z=192.3, direction=270 deg, width=85.5 mm\n", + "2026-04-08 17:52:21,538 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] release plate: x=482.9, y=306.2, z=192.3, direction=270 deg, width=85.5 mm\n" + ] + } + ], + "source": [ + "from pylabrobot.capabilities.arms.standard import GripDirection\n", + "\n", + "await star.iswap.pick_up_resource(plate, direction=GripDirection.LEFT)\n", + "await star.iswap.drop_resource(plate_carrier[2], direction=GripDirection.LEFT)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:52:27,002 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] pick up plate: x=482.9, y=306.2, z=192.3, direction=90 deg, width=85.5 mm\n", + "2026-04-08 17:52:33,870 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] release plate: x=482.9, y=210.2, z=192.3, direction=90 deg, width=85.5 mm\n" + ] + } + ], + "source": [ + "await star.iswap.move_resource(\n", + " plate, plate_carrier[1],\n", + " pickup_backend_params=iSWAPBackend.PickUpParams(grip_strength=8),\n", + " pickup_direction=GripDirection.RIGHT,\n", + " drop_direction=GripDirection.RIGHT,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Common tasks\n", + "\n", + "### Parking\n", + "\n", + "Park the iSWAP. Pass {class}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.ParkParams` via `backend_params` to control the minimum traverse height." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.park()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.park(\n", + " backend_params=iSWAPBackend.ParkParams(minimum_traverse_height=280),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Manual movement\n", + "\n", + "Move the iSWAP to an absolute or relative position. Useful for teaching and calibration. See [Manual movement (teaching / calibration)](#manual-movement-teaching--calibration) for a full walkthrough including coordinate conversion." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "# absolute (mm)\n", + "await star.driver.iswap.move_x(200)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_y(200)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_z(280)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "# relative (mm)\n", + "await star.driver.iswap.move_relative_x(step_size=10)\n", + "await star.driver.iswap.move_relative_y(step_size=10)\n", + "await star.driver.iswap.move_relative_z(step_size=-10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Opening the gripper\n", + "\n", + "Open the iSWAP gripper using {meth}`~pylabrobot.capabilities.arms.orientable_arm.OrientableArm.open_gripper`. **Warning**: this will release any object that is gripped. Useful for error recovery." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "await star.iswap.open_gripper(gripper_width=130)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Closing the gripper\n", + "\n", + "Close the iSWAP gripper. Pass {class}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.CloseGripperParams` via `backend_params` to control grip strength and plate width tolerance." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "As expected (no plate gripped): {'ISWAP': NoElementError('Plate not found')}\n" + ] + } + ], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.errors import STARFirmwareError\n", + "\n", + "try:\n", + " await star.iswap.close_gripper(gripper_width=120)\n", + "except STARFirmwareError as e:\n", + " print(f\"As expected (no plate gripped): {e.errors}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "As expected (no plate gripped): {'ISWAP': NoElementError('Plate not found')}\n" + ] + } + ], + "source": [ + "try:\n", + " await star.iswap.close_gripper(\n", + " gripper_width=85,\n", + " backend_params=iSWAPBackend.CloseGripperParams(grip_strength=8, plate_width_tolerance=1.0),\n", + " )\n", + "except STARFirmwareError as e:\n", + " print(f\"As expected (no plate gripped): {e.errors}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Rotations\n", + "\n", + "```{warning}\n", + "Rotating the iSWAP can cause collisions with other components on the deck. \n", + "Before rotating, move the iSWAP to a safe position clear of carriers and channels.\n", + "```\n", + "\n", + "You can rotate the iSWAP to 12 predefined positions using \n", + "{meth}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.rotate`.\n", + "\n", + "![iSWAP positions](img/iswap_positions.png)\n", + "\n", + "The `rotate` method takes a `rotation_drive` orientation and the final `grip_direction` \n", + "of the iSWAP (with respect to the deck). The internal motion planner automatically adjusts the wrist drive." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_x(200)\n", + "await star.driver.iswap.move_y(200)\n", + "await star.driver.iswap.move_z(280)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.capabilities.arms.standard import GripDirection\n", + "\n", + "await star.driver.iswap.rotate(\n", + " rotation_drive=iSWAPBackend.RotationDriveOrientation.RIGHT,\n", + " grip_direction=GripDirection.LEFT,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Controlling the wrist and rotation drive individually\n", + "\n", + "You can also control the wrist (T-drive) and rotation drive (W-drive) individually." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "# Rotation drive: LEFT, RIGHT, or FRONT\n", + "# Wrist drive: LEFT, RIGHT, STRAIGHT, or REVERSE\n", + "await star.driver.iswap.rotate_rotation_drive(iSWAPBackend.RotationDriveOrientation.LEFT)\n", + "await star.driver.iswap.rotate_wrist(iSWAPBackend.WristDriveOrientation.REVERSE)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Slow movement\n", + "\n", + "Use the {meth}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.slow` context manager to reduce speed during sensitive operations. Pass {class}`~pylabrobot.hamilton.liquid_handlers.star.iswap.iSWAPBackend.MoveToLocationParams` via `backend_params` for additional control over acceleration and collision avoidance." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:53:07,102 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] pick up plate: x=482.9, y=210.2, z=192.3, direction=0 deg, width=127.8 mm\n", + "2026-04-08 17:53:17,699 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] release plate: x=482.9, y=306.2, z=192.3, direction=0 deg, width=127.8 mm\n", + "2026-04-08 17:53:23,193 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] pick up plate: x=482.9, y=306.2, z=192.3, direction=0 deg, width=127.8 mm\n", + "2026-04-08 17:53:28,047 - pylabrobot.hamilton.liquid_handlers.star.iswap - INFO - [iSWAP] release plate: x=482.9, y=210.2, z=192.3, direction=0 deg, width=127.8 mm\n" + ] + } + ], + "source": [ + "async with star.driver.iswap.slow():\n", + " await star.iswap.move_resource(plate, plate_carrier[2])\n", + " await star.iswap.move_resource(plate, plate_carrier[1])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Manual movement (teaching / calibration)\n", + "\n", + "### 1. Clear the Y range\n", + "\n", + "For safety, move the other components as far away as possible before teaching." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'C0FYid0072er00/00'" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await star.driver.pip.position_components_for_free_iswap_y_range()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Set rotation\n", + "\n", + "Move the iSWAP wrist and rotation drive to the correct orientation as [explained above](#rotations). Be careful to move the iSWAP to a position where it does not hit any other components." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Absolute movement\n", + "\n", + "Use the following methods to move the iSWAP to absolute X, Y and Z positions. All units are in mm." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_x(x=200)" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_y(y=200)" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_z(z=270)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4. Computing plate position from calibration\n", + "\n", + "The x, y, and z values refer to the **center** of the iSWAP gripper, making them agnostic to plate size. In PLR, however, all locations are with respect to the left-front-bottom (LFB) of the plate. To convert from the calibrated center to LFB, subtract the plate's center-bottom anchor:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Coordinate\n", + "\n", + "x, y, z = 200, 200, 270 # calibrated center position\n", + "calibrated_position = Coordinate(x, y, z)\n", + "plate_lfb_absolute = calibrated_position - plate.get_anchor(\"c\", \"c\", \"b\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The plate's LFB is now in absolute coordinates. If the plate is a child of some parent resource, compute the relative location by subtracting the parent's absolute position:" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [], + "source": [ + "parent = plate_carrier # example: the plate's parent resource\n", + "parent_absolute = parent.get_location_wrt(star.deck)\n", + "plate_relative = plate_lfb_absolute - parent_absolute" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Use this with `parent.assign_child_resource(plate, location=plate_relative)` to place the plate at the calibrated position." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5. Relative movements\n", + "\n", + "You can also move the iSWAP relative to its current position. All units are in mm. These refer to the center of the gripper." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_relative_x(step_size=10)" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_relative_y(step_size=10)" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.iswap.move_relative_z(step_size=10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-08 17:53:40,341 - pylabrobot.io.usb - WARNING - Closing connection to USB device.\n" + ] + } + ], + "source": [ + "await star.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.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/hamilton/star/liquid-classes.ipynb b/docs/user_guide/hamilton/star/liquid-classes.ipynb new file mode 100644 index 00000000000..6aca38cfe08 --- /dev/null +++ b/docs/user_guide/hamilton/star/liquid-classes.ipynb @@ -0,0 +1,225 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Hamilton Liquid Classes\n", + "\n", + "This notebook demonstrates how to use Hamilton liquid classes with the STAR.\n", + "\"Liquid classes\" are sets of predefined parameters that describe a specific liquid transfer\n", + "operation (aspirate + dispense). While it is possible to control all parameters explicitly,\n", + "using \"Hamilton liquid classes\" is the historical way many people are used to doing this in\n", + "VENUS.\n", + "\n", + "PyLabRobot has imported many Hamilton liquid classes from VENUS. In this notebook we will\n", + "show how to use these classes." + ] + }, + { + "cell_type": "markdown", + "id": "cell-1", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Use `chatterbox=True` to simulate without connecting to a real Hamilton STAR robot.\n", + "Remove `chatterbox=True` (or pass `chatterbox=False`) to run on real hardware." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-2", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star import STARLet\n\nstar = STARLet(chatterbox=True)\n# star = STARLet() # for real hardware\nawait star.setup()" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-3", + "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\ntip_car = TIP_CAR_480_A00(name=\"tip carrier\")\ntip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\nstar.deck.assign_child_resource(tip_car, rails=3)\n\nplt_car = PLT_CAR_L5AC_A00(name=\"plate carrier\")\nplt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\nstar.deck.assign_child_resource(plt_car, rails=15)" + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "### Picking up tips for the rest of the notebook" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": "tiprack = star.deck.get_resource(\"tips_01\")\nplate = star.deck.get_resource(\"plate_01\")\n\nawait star.pip.pick_up_tips(tiprack[\"A1:C1\"])" + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "## Using a predefined Hamilton liquid class\n", + "\n", + "Pass a predefined Hamilton liquid class to `star.pip.aspirate` via\n", + "{class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.AspirateParams`.\n", + "This will use the parameters defined in the liquid class for the aspirate operation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend\n", + "from pylabrobot.liquid_handling.liquid_classes.hamilton.star import HighVolumeFilter_Water_DispenseSurface_Part\n", + "\n", + "await star.pip.aspirate(\n", + " plate[\"A1:C1\"],\n", + " vols=[100.0, 50.0, 200.0],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " hamilton_liquid_classes=[HighVolumeFilter_Water_DispenseSurface_Part] * 3,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "## Using a different Hamilton liquid class for each channel\n", + "\n", + "You can pass a list of different Hamilton liquid classes. They will correspond to the\n", + "channels in the order they are specified." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-9", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.liquid_handling.liquid_classes.hamilton.star import (\n", + " HighVolumeFilter_Water_DispenseSurface_Part,\n", + " HighVolumeFilter_EtOH_DispenseJet,\n", + " HighVolumeFilter_DMSO_AliquotDispenseJet_Part,\n", + ")\n", + "\n", + "await star.pip.aspirate(\n", + " plate[\"A1:C1\"],\n", + " vols=[100.0, 50.0, 200.0],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " hamilton_liquid_classes=[\n", + " HighVolumeFilter_Water_DispenseSurface_Part,\n", + " HighVolumeFilter_EtOH_DispenseJet,\n", + " HighVolumeFilter_DMSO_AliquotDispenseJet_Part,\n", + " ],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "## Using custom Hamilton liquid classes\n", + "\n", + "It is also possible to define your own Hamilton liquid classes. This is useful if you want\n", + "to use a specific set of parameters that are not available in the predefined classes.\n", + "\n", + "The example below is based on `HighVolumeFilter_Water_DispenseSurface_Part`, but you can\n", + "easily modify the parameters to suit your needs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "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 star.pip.aspirate(\n", + " plate[\"A1:C1\"],\n", + " vols=[100.0, 50.0, 200.0],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " hamilton_liquid_classes=[my_custom_hamilton_liquid_class] * 3,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-13", + "metadata": {}, + "outputs": [], + "source": [ + "await star.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/hamilton/star/lld.ipynb b/docs/user_guide/hamilton/star/lld.ipynb new file mode 100644 index 00000000000..e7dc04dbb81 --- /dev/null +++ b/docs/user_guide/hamilton/star/lld.ipynb @@ -0,0 +1,233 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Liquid level detection (LLD)\n", + "\n", + "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, b) a change in capacitance, or c) both. This is useful when 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.\n", + "\n", + "The LLD mode is specified via {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.LLDMode`, passed as a backend parameter to {meth}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.aspirate` or {meth}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.dispense`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star import STARLet\nfrom pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend, LLDMode\n\nstar = STARLet()\nawait star.setup()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set up a minimal deck with a tip rack and a tube rack (or plate) so we have something to aspirate from." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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\ntip_car = TIP_CAR_480_A00(name=\"tip carrier\")\ntip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\nstar.deck.assign_child_resource(tip_car, rails=3)\n\nplt_car = PLT_CAR_L5AC_A00(name=\"plate carrier\")\nplt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\nstar.deck.assign_child_resource(plt_car, rails=15)\n\ntiprack = star.deck.get_resource(\"tips_01\")\nplate = star.deck.get_resource(\"plate_01\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Pick up tips so we can demonstrate aspiration and dispensing with LLD." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.pick_up_tips(tiprack[\"A1\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## LLD modes\n", + "\n", + "To use LLD, pass a {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.LLDMode` list to `lld_mode` inside {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.AspirateParams` or {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.DispenseParams`.\n", + "\n", + "The available modes are:\n", + "\n", + "| Mode | Description |\n", + "|------|-------------|\n", + "| `LLDMode.OFF` | No LLD (default) |\n", + "| `LLDMode.GAMMA` | Capacitive LLD (cLLD) |\n", + "| `LLDMode.PRESSURE` | Pressure LLD (pLLD) |\n", + "| `LLDMode.DUAL` | Both capacitive and pressure LLD |\n", + "| `LLDMode.Z_TOUCH_OFF` | Find the bottom of the container |\n", + "\n", + "The `lld_mode` parameter is a list, so you can specify a different LLD mode for each channel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.aspirate(\n", + " plate[\"A1\"],\n", + " vols=[300],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " lld_mode=[LLDMode.GAMMA],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Immersion depth\n", + "\n", + "You can use the `immersion_depth` field on {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.AspirateParams` to move the tip relative to the detected liquid surface. A positive value means to go deeper into the liquid; a negative value means to stay above the liquid.\n", + "\n", + "Going 1 mm below the liquid surface for aspiration:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.aspirate(\n", + " plate[\"A1\"],\n", + " vols=[300],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " lld_mode=[LLDMode.GAMMA],\n", + " immersion_depth=[1],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Going 1 mm above the liquid surface for dispensing:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.dispense(\n", + " plate[\"A1\"],\n", + " vols=[300],\n", + " backend_params=STARPIPBackend.DispenseParams(\n", + " lld_mode=[LLDMode.GAMMA],\n", + " immersion_depth=[-1],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Surface following\n", + "\n", + "Through the `surface_following_distance` field on {class}`~pylabrobot.hamilton.liquid_handlers.star.pip_backend.STARPIPBackend.AspirateParams`, the tip can track the falling liquid surface during aspiration:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.aspirate(\n", + " plate[\"A1\"],\n", + " vols=[300],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " lld_mode=[LLDMode.GAMMA],\n", + " surface_following_distance=[10], # 10 mm\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Error handling\n", + "\n", + "All channelized pipetting operations raise a {class}`~pylabrobot.capabilities.liquid_handling.errors.ChannelizedError` when an error occurs, so that you get specific error information for each channel.\n", + "\n", + "When no liquid is found in the container, the channel will have a {class}`~pylabrobot.resources.errors.TooLittleLiquidError`. This is useful for detecting that your container is empty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError\n", + "from pylabrobot.resources.errors import TooLittleLiquidError\n", + "\n", + "try:\n", + " await star.pip.aspirate(\n", + " plate[\"A1\"],\n", + " vols=[300],\n", + " backend_params=STARPIPBackend.AspirateParams(\n", + " lld_mode=[LLDMode.GAMMA],\n", + " ),\n", + " )\n", + "except ChannelizedError as e:\n", + " if isinstance(e.errors[0], TooLittleLiquidError):\n", + " print(\"Too little liquid in well\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.drop_tips(tiprack[\"A1\"])\n", + "await star.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 +} \ No newline at end of file diff --git a/docs/user_guide/hamilton/star/probing/x.ipynb b/docs/user_guide/hamilton/star/probing/x.ipynb new file mode 100644 index 00000000000..d38830404cc --- /dev/null +++ b/docs/user_guide/hamilton/star/probing/x.ipynb @@ -0,0 +1,232 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ndn35pl2jha", + "metadata": {}, + "source": [ + "# X-probing\n", + "\n", + "With PyLabRobot, you can probe the x position of any conductive object on a STAR(let) deck using capacitive liquid level detection (cLLD). The channel is moved laterally along the x-axis until the cLLD sensor detects a change in capacitance, and the x-coordinate of the material boundary is returned.\n", + "\n", + "See also [y-probing](./y) and [z-probing](./z) for probing in the other directions.\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", + "id": "cg62t58dms", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Import {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR` and one of the Hamilton deck classes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "n33yxyupo1", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star import STAR\n\nstar = STAR()\nawait star.setup()" + }, + { + "cell_type": "markdown", + "id": "bn1699dg8r", + "metadata": {}, + "source": [ + "## Capacitive x-probing using cLLD\n", + "\n", + "X-probing uses the cLLD sensor to detect conductive surfaces by moving the channel laterally along the x-axis. Starting from the channel's current x position, the channel moves in the specified direction (`\"right\"` or `\"left\"`) until cLLD triggers. The returned value is a geometric estimate of the material boundary, corrected by half the tip bottom diameter.\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. Make sure the tip is at a safe z-height before initiating lateral x movement.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "zasq47h69z", + "metadata": {}, + "source": [ + "### Probing a single point\n", + "\n", + "Pick up a teaching tip:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "u4veczcf4jb", + "metadata": {}, + "outputs": [], + "source": "teaching_tip_rack = star.deck.get_resource(\"teaching_tip_rack\")\nawait star.pip.pick_up_tips(teaching_tip_rack[\"A1\"])" + }, + { + "cell_type": "markdown", + "id": "9l2wzf0lgl", + "metadata": {}, + "source": [ + "Prepare channel 0 for manual operation and move it to the desired y/z position. The channel must be at a z-height where lateral x movement is safe (i.e. the tip is at the same height as the surface to detect).\n", + "\n", + "For more information on manually moving channels, see [Manually moving channels around](../../00_liquid-handling/moving-channels-around.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "y1qhibn1m6m", + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.pip.prepare_for_manual_channel_operation(0)\n", + "\n", + "# TODO: change these to positions that work for you\n", + "await star.driver.left_x_arm.move_to(500)\n", + "await star.driver.pip.move_channel_y(0, 300)" + ] + }, + { + "cell_type": "markdown", + "id": "qq5wb8pj53", + "metadata": {}, + "source": [ + "Use `clld_probe_x_position` to probe the x-position of a conductive surface. The channel moves laterally in the specified direction until the cLLD sensor triggers. The estimated x-coordinate of the material boundary is returned, corrected by half the tip bottom diameter.\n", + "\n", + "You can probe in both directions:\n", + "- `\"right\"`: the channel moves toward higher x values (away from the instrument origin)\n", + "- `\"left\"`: the channel moves toward lower x values (toward the instrument origin)\n", + "\n", + "Note: x-probing is quite slow so it might appear that is not moving." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0wdghyaolwzf", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "501.2" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await star.driver.left_x_arm.clld_probe_x_position(\n", + " channel_idx=0,\n", + " probing_direction=\"right\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jarn5hmu1f", + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.left_x_arm.clld_probe_x_position(\n", + " channel_idx=0,\n", + " probing_direction=\"left\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7yeuj1j1f06", + "metadata": {}, + "source": [ + "### Optional parameters\n", + "\n", + "`clld_probe_x_position` accepts several optional parameters:\n", + "\n", + "- `end_pos_search` (float, mm): The x-position at which to stop searching. Defaults to the instrument's reachable limit in the probing direction.\n", + "- `post_detection_dist` (float, mm): Distance to retract after detection. Defaults to `2.0` mm.\n", + "- `tip_bottom_diameter` (float, mm): Diameter of the tip bottom, used for geometric correction. Defaults to `1.2` mm (teaching needle).\n", + "- `read_timeout` (float, seconds): Timeout for the probing command. Defaults to `240.0` seconds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "onqg7219znh", + "metadata": {}, + "outputs": [], + "source": [ + "await star.driver.left_x_arm.clld_probe_x_position(\n", + " channel_idx=0,\n", + " probing_direction=\"right\",\n", + " end_pos_search=600,\n", + " post_detection_dist=3.0,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "06dhmb1ilfd5", + "metadata": {}, + "source": [ + "Return the teaching tip when done:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "calgs333bpu", + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.return_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "rtuxq9vghr", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6072xgkh1y8", + "metadata": {}, + "outputs": [], + "source": [ + "await star.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.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/y-probing.ipynb b/docs/user_guide/hamilton/star/probing/y.ipynb similarity index 58% rename from docs/user_guide/00_liquid-handling/hamilton-star/y-probing.ipynb rename to docs/user_guide/hamilton/star/probing/y.ipynb index ec24c0aa731..3cf236bafe9 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-star/y-probing.ipynb +++ b/docs/user_guide/hamilton/star/probing/y.ipynb @@ -6,7 +6,7 @@ "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", + "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", @@ -17,7 +17,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Example setup" + "## Setup\n", + "\n", + "Import {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR` and one of the Hamilton deck classes." ] }, { @@ -25,14 +27,7 @@ "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()" - ] + "source": "from pylabrobot.hamilton.liquid_handlers.star import STAR\n\nstar = STAR()\nawait star.setup()" }, { "cell_type": "markdown", @@ -48,12 +43,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "teaching_tip = lh.deck.get_resource(\"teaching_tip_rack\")[\"A1\"]" + "Pick up a teaching tip:" ] }, { @@ -61,45 +54,53 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "await lh.pick_up_tips(teaching_tip)" - ] + "source": "teaching_tip = star.deck.get_resource(\"teaching_tip_rack\")[\"A1\"]\nawait star.pip.pick_up_tips(teaching_tip)" }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "await star.prepare_for_manual_channel_operation(0)" + "Prepare channel 0 for manual operation and move it to the desired x/y position:" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ + "await star.driver.pip.prepare_for_manual_channel_operation(0)\n", + "\n", "# 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)" + "await star.driver.left_x_arm.move_to(500)\n", + "await star.driver.pip.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." + "Use `clld_probe_y_position` 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-coordinate of the tip is then returned." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "217.4" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "await star.clld_probe_y_position_using_channel(\n", - " channel_idx=0,\n", + "await star.driver.pip.channels[0].clld_probe_y_position(\n", " probing_direction=\"forward\",\n", ")" ] @@ -110,19 +111,25 @@ "metadata": {}, "outputs": [], "source": [ - "await star.clld_probe_y_position_using_channel(\n", - " channel_idx=0,\n", + "await star.driver.pip.channels[0].clld_probe_y_position(\n", " probing_direction=\"backward\",\n", ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Return the teaching tip when done:" + ] + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.pip.return_tips()" ] } ], @@ -146,5 +153,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 -} + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/z-probing.ipynb b/docs/user_guide/hamilton/star/probing/z.ipynb similarity index 65% rename from docs/user_guide/00_liquid-handling/hamilton-star/z-probing.ipynb rename to docs/user_guide/hamilton/star/probing/z.ipynb index cc4dc2244dd..23f73558f86 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-star/z-probing.ipynb +++ b/docs/user_guide/hamilton/star/probing/z.ipynb @@ -6,7 +6,7 @@ "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", + "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) and [x probing](./x) for probing in the other directions.\n", "\n", "There are two ways to probe the surface of an object:\n", "\n", @@ -18,7 +18,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Example setup" + "## Setup\n", + "\n", + "Import {class}`~pylabrobot.hamilton.liquid_handlers.star.star.STAR` and one of the Hamilton deck classes." ] }, { @@ -26,30 +28,7 @@ "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)" - ] + "source": "from pylabrobot.hamilton.liquid_handlers.star import STARLet\nfrom 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\nstar = STARLet()\nawait star.setup()\n\n# assign a tip rack\ntip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\ntip_carrier[1] = tip_rack = hamilton_96_tiprack_50uL(name=\"tip_rack\")\nstar.deck.assign_child_resource(tip_carrier, rails=3)\n\n# assign a plate\nplt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\nplt_carrier[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plt\")\nstar.deck.assign_child_resource(plt_carrier, rails=10)" }, { "cell_type": "markdown", @@ -79,14 +58,14 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.pick_up_tips(tip_rack[\"A1\"])" + "await star.pip.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)." + "For more information on manually moving channels, see [Manually moving channels around](../../00_liquid-handling/moving-channels-around.ipynb)." ] }, { @@ -95,7 +74,7 @@ "metadata": {}, "outputs": [], "source": [ - "await star.prepare_for_manual_channel_operation(0)" + "await star.driver.pip.prepare_for_manual_channel_operation(0)" ] }, { @@ -105,15 +84,61 @@ "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)" + "await star.driver.left_x_arm.move_to(260)\n", + "await star.driver.pip.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." + "Use `clld_probe_z_height` 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.driver.pip.channels[0].clld_probe_z_height(move_channels_to_safe_pos_after=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.return_tips()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Parallel Z-probing\n", + "\n", + "Because each channel has its own firmware module, multiple channels can probe simultaneously using `asyncio.gather`. This is useful when you want to probe multiple points at the same time, for example to quickly map a surface across an entire row." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.pick_up_tips(tip_rack[\"A2:H2\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(8):\n", + " await star.driver.pip.prepare_for_manual_channel_operation(i)" ] }, { @@ -122,7 +147,12 @@ "metadata": {}, "outputs": [], "source": [ - "await star.clld_probe_z_height_using_channel(0, move_channels_to_safe_pos_after=True)" + "import asyncio\n", + "\n", + "await asyncio.gather(*[\n", + " star.driver.pip.channels[i].clld_probe_z_height(move_channels_to_safe_pos_after=True)\n", + " for i in range(8)\n", + "])" ] }, { @@ -131,7 +161,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.pip.return_tips()" ] }, { @@ -148,8 +178,8 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.pick_up_tips(tip_rack[\"A1\"])\n", - "await star.prepare_for_manual_channel_operation(0)" + "await star.pip.pick_up_tips(tip_rack[\"A1\"])\n", + "await star.driver.pip.prepare_for_manual_channel_operation(0)" ] }, { @@ -164,12 +194,12 @@ "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" + " await star.driver.left_x_arm.move_to(x)\n", + " for y in ys:\n", + " await star.driver.pip.move_channel_y(0, y)\n", + " height = await star.driver.pip.channels[0].clld_probe_z_height(start_pos_search=250)\n", + " data.append((x, y, height))\n", + " await star.driver.pip.channels[0].move_tool_z(230) # move up slightly for traversal" ] }, { @@ -178,7 +208,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.pip.return_tips()" ] }, { @@ -256,10 +286,7 @@ "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\"])" - ] + "source": "teaching_tip_rack = star.deck.get_resource(\"teaching_tip_rack\")\nawait star.pip.pick_up_tips(teaching_tip_rack[\"A2\"])" }, { "cell_type": "markdown", @@ -277,7 +304,7 @@ "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", + "Make sure the tip is at a safe height above the labware before moving the channel. Use `move_to_z_safety` to move the channel to a safe height.\n", "```" ] }, @@ -287,9 +314,9 @@ "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)" + "await star.driver.pip.prepare_for_manual_channel_operation(0)\n", + "await star.driver.left_x_arm.move_to(260)\n", + "await star.driver.pip.move_channel_y(0, 190)" ] }, { @@ -298,9 +325,8 @@ "metadata": {}, "outputs": [], "source": [ - "await star.ztouch_probe_z_height_using_channel(\n", - " channel_idx=0,\n", - " move_channels_to_save_pos_after=True)" + "await star.driver.pip.channels[0].ztouch_probe_z_height(\n", + " move_channels_to_save_pos_after=True)" ] }, { @@ -309,7 +335,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.pip.return_tips()" ] }, { @@ -336,7 +362,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.pick_up_tips(tip_rack[\"A1\"])" + "await star.pip.pick_up_tips(tip_rack[\"A1\"])" ] }, { @@ -348,7 +374,7 @@ "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", + "Make sure the tip is at a safe height above the labware before moving the channel. Use `move_to_z_safety` to move the channel to a safe height.\n", "```" ] }, @@ -358,7 +384,7 @@ "metadata": {}, "outputs": [], "source": [ - "await star.prepare_for_manual_channel_operation(0)" + "await star.driver.pip.prepare_for_manual_channel_operation(0)" ] }, { @@ -368,8 +394,8 @@ "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)" + "await star.driver.left_x_arm.move_to(260)\n", + "await star.driver.pip.move_channel_y(0, 190)" ] }, { @@ -385,7 +411,23 @@ "metadata": {}, "outputs": [], "source": [ - "await star.plld_probe_z_height_using_channel(0, move_channels_to_safe_pos_after=True)" + "await star.driver.pip.channels[0].plld_probe_z_height(move_channels_to_safe_pos_after=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star.pip.return_tips()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" ] }, { @@ -394,7 +436,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.return_tips()" + "await star.stop()" ] } ], @@ -412,11 +454,11 @@ "file_extension": ".py", "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", + "nbformat_minor": 2, "pygments_lexer": "ipython3", "version": "3.10.15" } }, "nbformat": 4, "nbformat_minor": 2 -} +} \ No newline at end of file diff --git a/docs/user_guide/hamilton/star/surface-following.ipynb b/docs/user_guide/hamilton/star/surface-following.ipynb new file mode 100644 index 00000000000..d64aed8d91e --- /dev/null +++ b/docs/user_guide/hamilton/star/surface-following.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "oihya3bqgdj", + "source": "# Surface following\n\nSurface 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\nWhen 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\nIt is useful to start the surface following only at the liquid level, so it is recommended to use [liquid level detection](./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\nIn 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)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "cx3av6hcfn8", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "atvymlmtwz9", + "source": "from pylabrobot.hamilton.liquid_handlers.star import STAR\n\nstar = STAR()\nawait star.setup()\n\nfrom pylabrobot.resources import (\n TIP_CAR_480_A00,\n PLT_CAR_L5AC_A00,\n CellTreat_96_wellplate_350ul_Ub,\n hamilton_96_tiprack_1000uL_filter,\n)\n\ntip_car = TIP_CAR_480_A00(name=\"tip carrier\")\ntip_car[0] = tr0 = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\nstar.deck.assign_child_resource(tip_car, rails=2)\n\nplt_car = PLT_CAR_L5AC_A00(name=\"plate carrier\")\nplt_car[0] = plate = CellTreat_96_wellplate_350ul_Ub(name=\"plate\")\nstar.deck.assign_child_resource(plt_car, rails=14)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mq05pxfkiwf", + "source": "## Automatic surface following", + "metadata": {} + }, + { + "cell_type": "code", + "id": "wiv85wgs06g", + "source": "from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend\n\nwells = plate[\"A1:H1\"]\nvols = [50] * len(wells)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mhcm05pfdhp", + "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:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "4kj4iy82lm3", + "source": "async with star.pip.use_tips(tr0[\"A1:H1\"], trash=star.deck.get_resource(\"trash\"), discard=False):\n await star.pip.aspirate(\n wells,\n vols,\n backend_params=STARPIPBackend.AspirateParams(\n # Probe the liquid height before aspirating.\n probe_liquid_height=True,\n # Automatically adjust the following distance based on the probed liquid height.\n auto_surface_following_distance=True,\n ),\n )\n\n await star.pip.dispense(\n wells,\n vols,\n backend_params=STARPIPBackend.DispenseParams(\n probe_liquid_height=True,\n auto_surface_following_distance=True,\n ),\n )", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "w10dhyuvd9", + "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. Note that `liquid_height` is a frontend parameter on `star.pip.aspirate()`, while `auto_surface_following_distance` is a backend parameter.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "rl1hb4go4pf", + "source": "async with star.pip.use_tips(tr0[\"A1:H1\"], trash=star.deck.get_resource(\"trash\"), discard=False):\n await star.pip.aspirate(\n wells,\n vols,\n liquid_height=[10] * len(wells), # in mm above the bottom of the well\n backend_params=STARPIPBackend.AspirateParams(\n auto_surface_following_distance=True,\n ),\n )\n\n await star.pip.dispense(\n wells,\n vols,\n liquid_height=[10] * len(wells), # in mm above the bottom of the well\n backend_params=STARPIPBackend.DispenseParams(\n auto_surface_following_distance=True,\n ),\n )", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "9r92q3bmbkt", + "source": "## Manual surface following\n\nTo manually specify the surface following amount, you can use the `surface_following_distance` backend parameter. For example, to aspirate 50 uL with a surface following distance of 2 mm starting at the detected liquid level:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6fyduw5bu8y", + "source": "async with star.pip.use_tips(tr0[\"A1:H1\"], trash=star.deck.get_resource(\"trash\"), discard=False):\n await star.pip.aspirate(\n wells,\n vols,\n backend_params=STARPIPBackend.AspirateParams(\n probe_liquid_height=True,\n surface_following_distance=[2] * len(wells), # mm down after finding liquid\n ),\n )\n\n await star.pip.dispense(\n wells,\n vols,\n backend_params=STARPIPBackend.DispenseParams(\n probe_liquid_height=True,\n surface_following_distance=[2] * len(wells), # mm up after finding liquid\n ),\n )", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 8eb3b01e98f..bb273f350cf 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -25,6 +25,27 @@ definitions 02_analytical/_analytical ``` +```{toctree} +:maxdepth: 1 +:caption: Manufacturers +:hidden: + +agilent/index +azenta/index +bmg_labtech/index +brooks/index +byonoy/index +hamilton/index +inheco/index +liconic/index +mettler_toledo/index +molecular_devices/index +opentrons/index +qinstruments/index +thermo_fisher/index +ufactory/index +``` + ```{toctree} :maxdepth: 1 :caption: Machine-Agnostic Features @@ -39,6 +60,14 @@ machine-agnostic-features/error-handling-general machine-agnostic-features/sila-discovery ``` +```{toctree} +:maxdepth: 1 +:caption: Capabilities +:hidden: + +capabilities/index +``` + ```{toctree} :maxdepth: 1 :caption: Configuration 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..41923dd2ca8 --- /dev/null +++ b/docs/user_guide/inheco/cpac/hello-world.ipynb @@ -0,0 +1,133 @@ +{ + "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 \u00b0C |\n", + "| CPAC Ultraflat HT 2TEC | -- | 7000166, 7000165 | +4 to +110 \u00b0C |\n", + "| CPAC Microplate | -- | 7000179 | +4 to +70 \u00b0C |\n", + "| CPAC Microplate HT 2TEC | -- | 7000163 | +4 to +110 \u00b0C |\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)\n", + "await cpac.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "The CPAC exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `cpac.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await cpac.tc.set_temperature(37.0)\n", + "await cpac.tc.wait_for_temperature(tolerance=0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await cpac.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await cpac.tc.deactivate()" + ] + }, + { + "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)\n", + "await cpac2.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await cpac.stop()\n", + "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..ef757853a95 --- /dev/null +++ b/docs/user_guide/inheco/incubator_shaker/hello-world.ipynb @@ -0,0 +1,215 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Inheco Incubator Shaker\n\nThe Inheco Incubator Shaker is a family of enclosed incubator devices with optional shaking, used for plate storage, temperature control, and orbital mixing. They support:\n\n- [Temperature control](../../capabilities/temperature-control) (heating, enclosed chamber for uniform temperature)\n- [Shaking](../../capabilities/shaking) (orbital/elliptical/figure-eight, on shaker models only)\n- Loading tray (automated drawer for plate access)\n- Plate presence sensing\n\nMultiple 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\nSee 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\nSet 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\nbackend = InhecoIncubatorShakerStackBackend(dip_switch_id=2)\nstack = IncubatorShakerStack(backend=backend)\nawait 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\nAccess 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]\nunit_1 = stack[1]" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Loading tray\n\nEach 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 ...\nawait stack[0].close()" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Check plate presence (reflection-based sensor)\nawait stack[0].request_plate_in_incubator() # returns True/False" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Temperature control\n\nAll models support heating with highly uniform temperature distribution inside the enclosed chamber. For the full API, see [Temperature Control](../../capabilities/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()\nprint(f\"{current:.1f} \u00b0C\")" + }, + { + "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\nawait 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\nShaker models (MP Shaker and DWP Shaker) support programmable shaking patterns. For the full API, see [Shaking](../../capabilities/shaking)." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Orbital shaking at 800 rpm (default pattern)\nawait 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\nThe 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\nawait stack.open_all()\nawait stack.close_all()" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Temperature control for all units\nawait stack.start_all_temperature_control(target_temperature=37)\nawait stack.get_all_temperatures() # {0: 37.1, 1: 36.8}\nawait stack.stop_all_temperature_control()" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Query states\nawait stack.request_loading_tray_states() # {0: 'closed', 1: 'closed'}\nawait stack.request_temperature_control_states() # {0: False, 1: False}\nawait stack.request_shaking_states() # {0: False, 1: False}" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Multiple stacks\n\nWhen 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\")\nstack_a = IncubatorShakerStack(backend=backend_a)\nawait stack_a.setup()\n\nbackend_b = InhecoIncubatorShakerStackBackend(dip_switch_id=7, port=\"/dev/cu.usbserial-42\")\nstack_b = IncubatorShakerStack(backend=backend_b)\nawait 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..2ee3ba0c774 --- /dev/null +++ b/docs/user_guide/inheco/odtc/hello-world.ipynb @@ -0,0 +1,159 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fa0retc47os", + "source": "# Inheco ODTC\n\nThe 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\nThe 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\nSee the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for hardware details.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "lhr3z1jpxeo", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1sizl6o45mj", + "source": "from pylabrobot.resources.coordinate import Coordinate\nfrom pylabrobot.legacy.thermocycling.inheco import ExperimentalODTCBackend\nfrom pylabrobot.legacy.thermocycling.thermocycler import Thermocycler\n\nodtc = 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),\n)\nawait odtc.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bml5t8cmqum", + "source": "## Door control\n\nOpen and close the motorized door for plate access:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "cvnumnehnmg", + "source": "await odtc.open_lid()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "e7gzc7c4uu5", + "source": "await odtc.close_lid()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ipw9nhnuyeb", + "source": "## Temperature control\n\nThe ODTC exposes block and lid temperature control. For general concepts, see [Temperature Control](../../capabilities/temperature-control).\n\n### Reading sensor data\n\nGet current temperatures from all sensors:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "wvs8p0vpi3", + "source": "sensor_data = await odtc.backend.get_sensor_data()\nprint(sensor_data)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "67olcts1713", + "source": "### Setting block temperature\n\nSet a constant block temperature. The ODTC uses a \"pre-method\" internally, which takes several minutes to stabilize the block and lid evenly before reaching the target.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "jmrk1sspss", + "source": "await odtc.set_block_temperature([37.0])", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "dipk9575div", + "source": "temp = await odtc.get_block_current_temperature()\nprint(f\"Block temperature: {temp[0]} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "7hw4nqxxmer", + "source": "await odtc.deactivate_block()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "e6lgmq4nhtl", + "source": "## Running PCR protocols\n\nThe ODTC can run multi-stage PCR protocols defined using `Protocol`, `Stage`, and `Step` objects. A protocol consists of stages, each containing steps with a target temperature and hold time. Stages can repeat multiple times for cycling.\n\n### Defining a protocol", + "metadata": {} + }, + { + "cell_type": "code", + "id": "y6cgg24rvb", + "source": "from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step\n\npcr_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, 30 s\n Step(temperature=[55.0], hold_seconds=30), # Anneal: 55 °C, 30 s\n Step(temperature=[72.0], hold_seconds=60), # Extend: 72 °C, 60 s\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)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "jm5ljrmir3g", + "source": "### Running the protocol", + "metadata": {} + }, + { + "cell_type": "code", + "id": "cks0kwzd8ac", + "source": "await odtc.run_protocol(\n protocol=pcr_protocol,\n block_max_volume=20.0, # maximum sample volume in uL\n start_block_temperature=25.0, # starting block temperature\n start_lid_temperature=105.0, # lid temperature (typically 105 °C to prevent condensation)\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "yrhel3at07", + "source": "### Custom ramp rates\n\nYou can specify custom temperature ramp rates (°C/s) for individual steps via the `rate` parameter:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ypntgxtgpp", + "source": "custom_protocol = Protocol(\n stages=[\n Stage(\n steps=[\n Step(temperature=[95.0], hold_seconds=60, rate=4.4), # fast ramp\n Step(temperature=[60.0], hold_seconds=30, rate=2.0), # slower ramp\n ],\n repeats=1,\n ),\n ]\n)\n\nawait 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)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "pkbix9va6y", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "99bdml259c", + "source": "await odtc.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file 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..d226157b7dc --- /dev/null +++ b/docs/user_guide/inheco/scila/hello-world.ipynb @@ -0,0 +1,187 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "t0q0pzftygm", + "source": "# Inheco SCILA\n\nThe 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
|\n\n**Capabilities:**\n- [Temperature control](../../capabilities/temperature-control) (heating, single zone shared across all drawers)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "qnrt30ol6yc", + "source": "## Setup\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.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "z5fcugljbwk", + "source": "from pylabrobot.inheco.scila import SCILA\n\nscila = SCILA(name=\"scila\", scila_ip=\"169.254.1.117\") # replace with your IP\nawait scila.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "b5tlrcjqnaw", + "source": "## Status Requests\n\nQuery the overall device status (`\"idle\"`, `\"standBy\"`, `\"inError\"`, `\"startup\"`, ...):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "41szb6vodyd", + "source": "await scila.driver.request_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mubpx1eq1gb", + "source": "Water level in the built-in humidification reservoir (e.g. `\"High\"`, `\"Low\"`, `\"Empty\"`):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "mropytllj29", + "source": "await scila.driver.request_liquid_level()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ssaxq21iau", + "source": "Drawer status for all 4 drawers, or a single drawer:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1c3ph2ocvsy", + "source": "await scila.driver.request_drawer_statuses()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "ku94ko4ros", + "source": "await scila.driver.request_drawer_status(1)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "p7i6hqwkt1", + "source": "CO₂ and H₂O valve status:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "akbcybg86xv", + "source": "await scila.driver.request_valve_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "g6s5bqaxtwc", + "source": "CO₂ flow status:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "sqo8u77mw5c", + "source": "await scila.driver.request_co2_flow_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "j4158a7ie6l", + "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.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7k64plxbq", + "source": "await scila.drawers[2].open()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2z6ayudjc48", + "source": "await scila.drawers[2].close()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "eb1yorphqlv", + "source": "## Temperature Control\n\nThe SCILA has a single temperature zone shared across all 4 drawers. Temperature control is exposed via a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `scila.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "o3x6m4pn4k", + "source": "current = await scila.tc.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "6wx6jnosfzv", + "source": "await scila.tc.set_temperature(37.0)\nawait scila.tc.wait_for_temperature(tolerance=0.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "4j9wjfu9t2e", + "source": "Stop temperature control:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "wcmkz8nnt7b", + "source": "await scila.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ryrjtlm67v", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ebejgue4wyn", + "source": "await scila.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/inheco/scila/img/inheco_scila.png b/docs/user_guide/inheco/scila/img/inheco_scila.png new file mode 100644 index 00000000000..9a207e0668d Binary files /dev/null and b/docs/user_guide/inheco/scila/img/inheco_scila.png differ 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..4dbedc28654 --- /dev/null +++ b/docs/user_guide/inheco/thermoshake/hello-world.ipynb @@ -0,0 +1,123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9tc0hvg3nb", + "source": "# Inheco ThermoShake\n\nThe Inheco ThermoShake is a combined heater-cooler-shaker that supports:\n\n- [Shaking](../../capabilities/shaking) (orbital, 60--2000 rpm depending on variant)\n- [Temperature control](../../capabilities/temperature-control) (4 °C to 105 °C, max 25 °C below ambient for cooling)\n\nAll 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\nSee 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]`).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "iyjhvd0ewmi", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "jdb9stxlizk", + "source": "from pylabrobot.inheco import InhecoTECControlBox, inheco_thermoshake\n\ncontrol_box = InhecoTECControlBox()\nawait control_box.setup()\n\nts = inheco_thermoshake(name=\"ts\", control_box=control_box, index=1)\nawait ts.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "zftm4fgc8uj", + "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.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "w3zwtxym6aa", + "source": "## Shaking\n\nThe ThermoShake exposes a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` on `ts.shaker`. For the full API, see [Shaking](../../capabilities/shaking).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "a0du4u05wub", + "source": "# Shake indefinitely at 500 rpm\nawait ts.shaker.shake(speed=500)\n\n# ... do other things ...\n\nawait ts.shaker.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "0ltnuzf3lsv", + "source": "# Shake for 10 seconds at 300 rpm (blocks until done)\nawait ts.shaker.shake(speed=300, duration=10)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "u1evtvw4gq", + "source": "## Temperature control\n\nThe ThermoShake exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `ts.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "fundvc36m8o", + "source": "await ts.tc.set_temperature(37.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "gppm06002e", + "source": "current = await ts.tc.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "h1qrrojgrig", + "source": "await ts.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "d09ekquz3y7", + "source": "## Multiple devices\n\nWhen using multiple Inheco devices on one control box, create each with a unique `index`:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "3wnpwaknynj", + "source": "import asyncio\n\nts1 = inheco_thermoshake(name=\"ts1\", control_box=control_box, index=1)\nts2 = inheco_thermoshake(name=\"ts2\", control_box=control_box, index=2)\n\nawait asyncio.gather(ts1.setup(), ts2.setup())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "vvjbyllpl7", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "18p0yuw8kd3", + "source": "await ts.stop()\nawait control_box.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/liconic/index.md b/docs/user_guide/liconic/index.md new file mode 100644 index 00000000000..26e50557559 --- /dev/null +++ b/docs/user_guide/liconic/index.md @@ -0,0 +1,7 @@ +# Liconic + +```{toctree} +:maxdepth: 1 + +stx/hello-world +``` diff --git a/docs/user_guide/liconic/stx/hello-world.ipynb b/docs/user_guide/liconic/stx/hello-world.ipynb new file mode 100644 index 00000000000..f5a5e0648cd --- /dev/null +++ b/docs/user_guide/liconic/stx/hello-world.ipynb @@ -0,0 +1,177 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6ru9zu8rvgh", + "source": "# Liconic STX\n\nThe Liconic STX line of automated incubators comes in a variety of sizes (STX 44, STX 110, STX 220, STX 280, STX 500, STX 1000 -- corresponding to the number of plate positions) and climate control options:\n\n| Suffix | Climate type | Temp. control | Active cooling | Humidity control |\n|---|---|---|---|---|\n| IC | Incubator | yes | no | no |\n| HC | Humid Cooler | yes | yes | no |\n| DC2 | Dry Storage | yes | no | yes |\n| HR | Humid Wide Range | yes | yes | no |\n| DR2 | Dry Wide Range | yes | no | yes |\n| AR | Humidity Controlled | yes | no | yes |\n| DF | Deep Freezer | yes | yes | no |\n| NC | No Climate | no | no | no |\n| DH | Dehumidifier | yes | no | yes |\n\nCassettes are available for plate heights from 5 mm to 104 mm and can be mixed within a single unit.\n\nDepending on configuration, the STX supports:\n\n- [Automated retrieval](../../capabilities/automated-retrieval) (store/fetch plates by position or strategy)\n- [Temperature control](../../capabilities/temperature-control) (heating, active cooling on HC/HR/DF models)\n- [Humidity control](../../capabilities/humidity-control) (on DC2/DR2/AR/DH models)\n- [Shaking](../../capabilities/shaking) (optional internal shaker)\n- [Barcode scanning](../../capabilities/barcode-scanning) (optional internal scanner)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "zyc01aslxmd", + "source": "## Setup\n\nCreate a `Liconic` instance with the model string (e.g. `\"STX220_HC\"`), the serial port, and a list of rack cassettes matching your physical configuration. Connect via RS232.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "78n6b9bza8s", + "source": "from pylabrobot.liconic import Liconic\nfrom pylabrobot.liconic.racks import liconic_rack_17mm_22, liconic_rack_44mm_10\nfrom pylabrobot.resources import Coordinate\n\nracks = [\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\nincubator = Liconic(\n name=\"incubator\",\n liconic_model=\"STX220_HC\",\n port=\"/dev/ttyUSB0\", # replace with your port\n racks=racks,\n loading_tray_location=Coordinate(x=0, y=0, z=0),\n)\n\nawait incubator.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ox6t79wnst", + "source": "Racks can be mixed -- here we use 44 mm cassettes (10 plates each) for taller plates and 17 mm cassettes (22 plates each) for standard-height plates. See `pylabrobot.liconic.racks` for the full list of available cassette types.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "z1g5xvvw0an", + "source": "## Storing and retrieving plates\n\nPlace a plate on the loading tray, then call `take_in_plate` to store it. You can specify a strategy (`\"smallest\"` picks the smallest free site that fits, `\"random\"` picks a random one) or pass a specific rack site. For the full API, see [Automated Retrieval](../../capabilities/automated-retrieval).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "562vs3qn86", + "source": "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n\nplate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"my_plate\")\nincubator.loading_tray.assign_child_resource(plate)\n\nawait incubator.take_in_plate(\"smallest\") # store in the smallest free site that fits", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "lhcpvo2os0s", + "source": "Retrieve a plate by name:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "2w9vsj6i8bp", + "source": "await incubator.fetch_plate_to_loading_tray(plate_name=\"my_plate\")\nretrieved = incubator.loading_tray.resource", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "y8dhvwx6fwm", + "source": "You can also store at a specific rack site or use `\"random\"`:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "rl1sabpoa6i", + "source": "await incubator.take_in_plate(\"random\") # random free site\n# await incubator.take_in_plate(racks[3].sites[0]) # specific rack and position", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ednr8bps09g", + "source": "Print a summary of what is stored where:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "rvqkhqaii3r", + "source": "print(incubator.summary())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "5w1rdh4gnld", + "source": "## Temperature control\n\nModels with temperature control (all except `_NC`) expose a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `incubator.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "vubj2yqgir", + "source": "await incubator.tc.set_temperature(37.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "jcrwi8xgpgq", + "source": "current = await incubator.tc.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "1mgv2un629z", + "source": "## Humidity control\n\nModels with humidity control (`_DC2`, `_DR2`, `_AR`, `_DH`) expose a {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` on `incubator.humidity_controller`. For the full API, see [Humidity Control](../../capabilities/humidity-control).\n\nHumidity is expressed as a fraction (0.0--1.0), not a percentage.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1k625wrxt4t", + "source": "# Only available on models with humidity control (DC2, DR2, AR, DH)\n# await incubator.humidity_controller.set_humidity(0.95) # 95% RH\n# current_rh = await incubator.humidity_controller.request_humidity()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "oo6iuis1qdk", + "source": "## Shaking\n\nIf your STX has an internal shaker installed, pass `has_shaker=True` when constructing the `Liconic`. The shaker is then available at `incubator.shaker`. For the full API, see [Shaking](../../capabilities/shaking).\n\nNote: shaking speed on the Liconic is specified in Hz (1.0--50.0), not RPM.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7irud68zfns", + "source": "# Only if has_shaker=True was passed during construction:\n# await incubator.shaker.shake(speed=10.0) # 10 Hz\n# await incubator.shaker.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "b4lkz4atm0g", + "source": "## CO2 and N2 gas control\n\nThe backend exposes direct methods for CO2 and N2 gas level control (when the hardware is equipped):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "50q0mm1ik94", + "source": "# CO2 control (if installed)\n# await incubator.driver.set_co2_level(0.05) # 5% CO2\n# co2 = await incubator.driver.request_co2_level()\n\n# N2 control (if installed)\n# await incubator.driver.set_n2_level(0.10) # 10% N2\n# n2 = await incubator.driver.request_n2_level()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "388eeo49sc2", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "p1jop1zbcpg", + "source": "await incubator.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/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 759f47694fe..dc057d79b15 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -167,7 +167,7 @@ tr > td:nth-child(5) { width: 15%; } | 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 | [PLR](02_analytical/plate-reading/pico.ipynb) / [OEM](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico) | -| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [PLR](02_analytical/plate-reading/tecan-infinite.ipynb) / [OEM](https://lifesciences.tecan.com/infinite-200-pro) | +| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [PLR](tecan/infinite/hello-world.ipynb) / [OEM](https://lifesciences.tecan.com/infinite-200-pro) | ### Flow Cytometers 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..7294a987eb9 --- /dev/null +++ b/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb @@ -0,0 +1,113 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5pmhklv4dll", + "source": "# Mettler Toledo WXS205SDU\n\nThe 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\nThe 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.\n\n**Capabilities:** [Weighing](../../capabilities/weighing)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "8v1qlztfpn", + "source": "## Physical setup\n\nThe 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\nConnect 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}\nThe 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```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "1mzumf8u2bu", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "djeqvngxd3c", + "source": "from pylabrobot.mettler_toledo import MettlerToledoWXS205SDUDriver, MettlerToledoWXS205SDUScaleBackend\nfrom pylabrobot.capabilities.weighing import Scale\n\ndriver = MettlerToledoWXS205SDUDriver(port=\"/dev/cu.usbserial-110\") # replace with your port\nbackend = MettlerToledoWXS205SDUScaleBackend(driver=driver)\nscale = Scale(backend=backend)\n\nawait driver.setup()\nawait scale.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "opa273q0bvc", + "source": "## Weighing\n\nThe scale exposes the standard [Weighing](../../capabilities/weighing) capability: `zero()`, `tare()`, and `read_weight()`.\n\n### Zero\n\nCalibrates the scale to read zero with an empty platform. Use at the start of a workflow.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "dqc8j70q1m", + "source": "await scale.zero()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "jtaypw6mx8", + "source": "### Tare\n\nResets 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.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "k0b0bx9qaqq", + "source": "await scale.tare()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "i8ewtl6bitj", + "source": "### Read weight\n\nReturns the current weight in grams.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "77so0rlhk39", + "source": "weight = await scale.read_weight()\nprint(f\"Weight: {weight} g\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "p4p4hpeg11", + "source": "### Backend-specific methods\n\nThe backend exposes additional methods beyond the standard capability interface. You can access them through `scale.backend`:\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). See the [Weighing capability docs](../../capabilities/weighing) for details on timeout modes.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7ha7yrkc069", + "source": "tare_value = await scale.backend.request_tare_weight()\nprint(f\"Stored tare: {tare_value} g\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "oq3ck6rntr", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "q7m594v11eb", + "source": "await scale.stop()\nawait driver.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/molecular_devices/imageXpress/pico.ipynb b/docs/user_guide/molecular_devices/imageXpress/pico.ipynb new file mode 100644 index 00000000000..adb35c00ac5 --- /dev/null +++ b/docs/user_guide/molecular_devices/imageXpress/pico.ipynb @@ -0,0 +1,196 @@ +{ + "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\n", + "from pylabrobot.capabilities.microscopy import ImagingMode, Objective\n", + "\n", + "pico = Pico(\n", + " name=\"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": [ + "## Plate setup\n", + "\n", + "The Pico derives labware geometry (well spacing, bottom thickness, etc.) from the PLR plate definition, so any plate resource works:" + ] + }, + { + "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\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Imaging\n\nThe Pico exposes a {class}`~pylabrobot.capabilities.microscopy.microscopy.Microscopy` capability on `pico.microscope`. For the full API including auto-exposure and auto-focus, see [Microscopy](../../capabilities/microscopy)." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Brightfield capture" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "result = await pico.microscope.capture(\n well=plate.get_well(\"A1\"),\n mode=ImagingMode.BRIGHTFIELD,\n objective=Objective.O_10X_PL_FL,\n plate=plate,\n exposure_time=50.0, # ms\n focal_height=5.0, # mm\n gain=1.0,\n)\n\nprint(f\"Captured {len(result.images)} image(s)\")\nprint(f\"Exposure: {result.exposure_time} ms, focal height: {result.focal_height} mm\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Fluorescence capture" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "result = await pico.microscope.capture(\n well=plate.get_well(\"B3\"),\n mode=ImagingMode.DAPI,\n objective=Objective.O_20X_PL_FL,\n plate=plate,\n exposure_time=100.0,\n focal_height=5.0,\n gain=1.0,\n)" + }, + { + "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` |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Door control\n", + "\n", + "Open and close the plate drawer via the driver:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pico.driver.open_door()\n", + "# load plate\n", + "await pico.driver.close_door()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Objective maintenance\n", + "\n", + "To physically swap an objective, enter maintenance mode for the turret position:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await pico.microscope.backend.enter_objective_maintenance(position=0)\n# swap the objective\nawait pico.microscope.backend.exit_objective_maintenance()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "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.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file 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..f91146d9cfd --- /dev/null +++ b/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb @@ -0,0 +1,278 @@ +{ + "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 driver ({class}`~pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver`), but differ in capabilities.\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 built-in plate holder:" + ] + }, + { + "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.plate_holder.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "Both the M5 and 384 Plus support absorbance reads. For the full API, see [Absorbance](../../capabilities/absorbance).\n", + "\n", + "Use {class}`~pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesAbsorbanceBackend.AbsorbanceParams` to configure backend-specific settings like speed reads, path check, and kinetic reads." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.absorbance.read_absorbance(plate, wavelength=450)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With backend params:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.molecular_devices.spectramax.backend import (\n Calibrate,\n MolecularDevicesAbsorbanceBackend,\n)\n\nresults = await reader.absorbance.read_absorbance(\n plate,\n wavelength=450,\n backend_params=MolecularDevicesAbsorbanceBackend.AbsorbanceParams(\n speed_read=True,\n calibrate=Calibrate.ONCE,\n ),\n)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fluorescence (M5 only)\n", + "\n", + "The SpectraMax M5 supports fluorescence reads. For the full API, see [Fluorescence](../../capabilities/fluorescence).\n", + "\n", + "Use {class}`~pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5FluorescenceBackend.FluorescenceParams` to configure excitation/emission wavelengths, cutoff filters, PMT gain, and other settings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.fluorescence.read_fluorescence(\n", + " plate,\n", + " excitation_wavelength=485,\n", + " emission_wavelength=520,\n", + " focal_height=0.0,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With backend params for PMT gain and bottom-read:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5FluorescenceBackend\n", + "from pylabrobot.molecular_devices.spectramax.backend import PmtGain\n", + "\n", + "results = await reader.fluorescence.read_fluorescence(\n", + " plate,\n", + " excitation_wavelength=485,\n", + " emission_wavelength=520,\n", + " focal_height=0.0,\n", + " backend_params=SpectraMaxM5FluorescenceBackend.FluorescenceParams(\n", + " pmt_gain=PmtGain.HIGH,\n", + " read_from_bottom=True,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Luminescence (M5 only)\n", + "\n", + "The SpectraMax M5 supports luminescence reads. For the full API, see [Luminescence](../../capabilities/luminescence).\n", + "\n", + "Use {class}`~pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5LuminescenceBackend.LuminescenceParams` to configure emission wavelengths, PMT gain, and other settings. Note that `emission_wavelengths` is required for luminescence reads on the M5." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5LuminescenceBackend\n", + "\n", + "results = await reader.luminescence.read_luminescence(\n", + " plate,\n", + " focal_height=0.0,\n", + " backend_params=SpectraMaxM5LuminescenceBackend.LuminescenceParams(\n", + " emission_wavelengths=[460],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Both models expose a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `reader.tc`. Temperature range is 0--45 C. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.tc.set_temperature(37.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await reader.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.tc.deactivate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tray control\n", + "\n", + "Open and close the plate tray via the driver:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.driver.open()\n", + "# load plate\n", + "await reader.driver.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 +} \ No newline at end of file diff --git a/docs/user_guide/opentrons/index.md b/docs/user_guide/opentrons/index.md new file mode 100644 index 00000000000..afe03a1f052 --- /dev/null +++ b/docs/user_guide/opentrons/index.md @@ -0,0 +1,7 @@ +# Opentrons + +```{toctree} +:maxdepth: 1 + +temperature_module/hello-world +``` diff --git a/docs/user_guide/opentrons/temperature_module/hello-world.ipynb b/docs/user_guide/opentrons/temperature_module/hello-world.ipynb new file mode 100644 index 00000000000..dc979189b3a --- /dev/null +++ b/docs/user_guide/opentrons/temperature_module/hello-world.ipynb @@ -0,0 +1,117 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3k09zranzo", + "source": "# Opentrons Temperature Module\n\nThe Opentrons Temperature Module GEN2 is a Peltier-based temperature controller for microplates and tube racks. It supports heating (up to 95 °C) and active cooling (down to 4 °C).\n\n| Model | PLR Name | Temperature Range | Communication |\n|---|---|---|---|\n| Temperature Module GEN2 | `OpentronsTemperatureModuleV2` | 4--95 °C | USB via OT-2 HTTP API or direct USB serial |\n\nThe module can be used in two modes:\n\n- **Via the Opentrons robot** -- communicates through the OT-2 HTTP API (requires `ot_api`).\n- **Via direct USB serial** -- communicates over a serial port, no OT-2 robot required.\n\nSee the [Opentrons product page](https://opentrons.com/products/temperature-module-gen2) for hardware details.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "37n9b1zwexf", + "source": "## Setup (via Opentrons robot)\n\nWhen using the module through an OT-2, first set up the liquid handler and then list connected modules to find the module ID.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "pnanakk6tc", + "source": "from pylabrobot.liquid_handling import LiquidHandler\nfrom pylabrobot.liquid_handling.backends.opentrons_backend import OpentronsBackend\nfrom pylabrobot.resources.opentrons import OTDeck\n\not = OpentronsBackend(host=\"169.254.184.185\", port=31950) # replace with your robot's IP\nlh = LiquidHandler(backend=ot, deck=OTDeck())\nawait lh.setup()\n\n# List connected modules to find the temperature module ID\nawait ot.list_connected_modules()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2vwfqicovzy", + "source": "from pylabrobot.opentrons import OpentronsTemperatureModuleV2\n\nt = OpentronsTemperatureModuleV2(\n name=\"t\",\n opentrons_id=\"fc409cc91770129af8eb0a01724c56cb052b306a\", # from list_connected_modules()\n)\nawait t.setup()\n\n# Assign the module to a deck slot\nlh.deck.assign_child_at_slot(t, slot=3)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "inp31u4nbsb", + "source": "## Setup (via direct USB serial)\n\nIf you are not using an OT-2 robot, connect the temperature module directly to your computer via USB and specify the serial port.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "apm1skjumct", + "source": "from pylabrobot.opentrons import OpentronsTemperatureModuleV2\n\nt = OpentronsTemperatureModuleV2(name=\"t\", serial_port=\"/dev/ttyACM0\") # replace with your port\nawait t.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "pa46y68qxqm", + "source": "## Temperature control\n\nThe module exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `t.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "yvqslma98qp", + "source": "await t.tc.set_temperature(37.0)\nawait t.tc.wait_for_temperature()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "4j2aw12250j", + "source": "current = await t.tc.request_temperature()\nprint(f\"{current:.1f} \\u00b0C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "pjkz1t7ukg", + "source": "await t.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qcegwv91qf8", + "source": "## Pipetting from the temperature module\n\nWhen using the module on an OT-2 deck, you can pipette directly to/from its child resource (e.g. a tube rack or well plate). Assign the child resource when creating the module, or afterwards.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "yacd5q0rg", + "source": "from pylabrobot.resources.opentrons import opentrons_96_tiprack_300ul\n\ntips300 = opentrons_96_tiprack_300ul(name=\"tips\")\nlh.deck.assign_child_at_slot(tips300, slot=11)\n\nawait lh.pick_up_tips(tips300[\"A5\"])\nawait lh.aspirate(t.resource[\"A1\"], vols=[20])\nawait lh.return_tips()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "77tmxouz0xp", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "0fv85jkoto5v", + "source": "await t.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file 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..b8bab0c645e --- /dev/null +++ b/docs/user_guide/qinstruments/bioshake/hello-world.ipynb @@ -0,0 +1,196 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# QInstruments BioShake\n\nThe BioShake is a family of heater-cooler-shaker devices from QInstruments. Depending on the model, it supports:\n\n- [Shaking](../../capabilities/shaking) (orbital, 200--5000 rpm)\n- [Temperature control](../../capabilities/temperature-control) (heating, and active cooling on Q1/Q2/ColdPlate)\n- Plate locking (ELM models)\n\nAll 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\nSee 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()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, `setup()` resets the device and homes the shaker. Pass `skip_home=True` to skip this." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "The BioShake exposes a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` on `bs.shaker`. For the full API, see [Shaking](../../capabilities/shaking)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Shake for 60 seconds at 500 rpm (blocks until done)\n", + "await bs.shaker.shake(speed=500, duration=60)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Or start/stop manually\n", + "await bs.shaker.shake(speed=300)\n", + "# ... do other things ...\n", + "await bs.shaker.stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "BioShake supports acceleration and deceleration ramps (seconds to reach full speed). Pass these as backend params:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.shaker.shake(speed=500, acceleration=3)\n", + "await bs.shaker.stop_shaking(deceleration=5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lock and unlock the plate (ELM models):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.shaker.lock_plate()\n", + "await bs.shaker.shake(speed=1000, duration=10)\n", + "await bs.shaker.unlock_plate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Models with heating/cooling expose a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `bs.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.tc.set_temperature(37.0)\n", + "await bs.tc.wait_for_temperature(tolerance=0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await bs.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.tc.deactivate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple devices\n", + "\n", + "When using multiple BioShake devices, run them concurrently with `asyncio.gather`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "from pylabrobot.qinstruments import BioShake3000Elm\n", + "\n", + "bs1 = BioShake3000Elm(name=\"bs1\", port=\"COM1\")\n", + "bs2 = BioShake3000Elm(name=\"bs2\", port=\"COM2\")\n", + "\n", + "await asyncio.gather(bs1.setup(), bs2.setup())\n", + "await asyncio.gather(\n", + " bs1.shaker.shake(speed=500, duration=30),\n", + " bs2.shaker.shake(speed=500, duration=30),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file 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/tecan/index.md b/docs/user_guide/tecan/index.md new file mode 100644 index 00000000000..5911f1bca86 --- /dev/null +++ b/docs/user_guide/tecan/index.md @@ -0,0 +1,7 @@ +# Tecan + +```{toctree} +:maxdepth: 1 + +infinite/hello-world +``` diff --git a/docs/user_guide/tecan/infinite/hello-world.ipynb b/docs/user_guide/tecan/infinite/hello-world.ipynb new file mode 100644 index 00000000000..d91ff1af75b --- /dev/null +++ b/docs/user_guide/tecan/infinite/hello-world.ipynb @@ -0,0 +1,132 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Tecan Infinite 200 PRO\n\nThe Tecan Infinite 200 PRO is a multimode microplate reader that supports:\n\n- [Absorbance](../../capabilities/absorbance) (230--1000 nm)\n- [Fluorescence](../../capabilities/fluorescence) (230--850 nm excitation/emission)\n- [Luminescence](../../capabilities/luminescence)\n\nThis backend targets the Infinite \"M\" series (e.g., Infinite 200 PRO M Plex). The \"F\" series uses a different optical path and is not covered here." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.tecan.infinite import TecanInfinite200Pro\n\nreader = TecanInfinite200Pro(name=\"reader\")\nawait reader.setup()" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await reader.loading_tray.open()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "Before closing, assign a plate to the 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\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nreader.loading_tray.assign_child_resource(plate)" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await reader.loading_tray.close()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Absorbance\n\nRead absorbance at a specified wavelength (230--1000 nm). For the full API, see [Absorbance](../../capabilities/absorbance)." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "results = await reader.absorbance.read(plate=plate, wavelength=450)\nresults[0].data # 2D array indexed [row][col]" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### Backend-specific parameters\n\nUse {class}`~pylabrobot.tecan.infinite.TecanInfiniteAbsorbanceParams` to configure flashes and bandwidth." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.tecan.infinite import TecanInfiniteAbsorbanceParams\n\nresults = await reader.absorbance.read(\n plate=plate,\n wavelength=450,\n backend_params=TecanInfiniteAbsorbanceParams(flashes=50, bandwidth=9.0),\n)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Fluorescence\n\nRead fluorescence with specified excitation and emission wavelengths (230--850 nm). The focal height is in millimeters. For the full API, see [Fluorescence](../../capabilities/fluorescence)." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "results = await reader.fluorescence.read(\n plate=plate,\n excitation_wavelength=485,\n emission_wavelength=528,\n focal_height=20.0,\n)\nresults[0].data" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Luminescence\n\nRead luminescence. The focal height is in millimeters. For the full API, see [Luminescence](../../capabilities/luminescence)." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "results = await reader.luminescence.read(plate=plate, focal_height=20.0)\nresults[0].data" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Reading specific wells\n\nYou 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\"])\nresults = await reader.absorbance.read(plate=plate, wavelength=450, wells=wells)" + }, + { + "cell_type": "markdown", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "source": "await reader.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "source": "## Installation\n\nThe Infinite 200 PRO connects via USB. PyLabRobot uses `pyusb` for communication, which requires `libusb` on your system.\n\n### macOS\n\n```bash\nbrew install libusb\n```\n\n### Linux (Debian/Ubuntu)\n\n```bash\nsudo apt-get install libusb-1.0-0-dev\n```\n\n### Windows\n\nInstall [Zadig](https://zadig.akeo.ie/) and replace the Infinite's default USB driver with `WinUSB` or `libusb-win32`.", + "metadata": {} + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb b/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb new file mode 100644 index 00000000000..8292ff494b0 --- /dev/null +++ b/docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb @@ -0,0 +1,155 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3bp5p2lik7e", + "source": "# Thermo Fisher Cytomat\n\nThe Cytomat series of incubators stores microplates under controlled environmental conditions. PLR supports several models through two backends:\n\n- {class}`~pylabrobot.thermo_fisher.cytomat.backend.CytomatBackend` -- for modern Cytomat models (serial protocol)\n- {class}`~pylabrobot.thermo_fisher.cytomat.heraeus_backend.HeraeusCytomatBackend` -- for legacy Heraeus-era Cytomats (PLC protocol)\n\n| Model | `CytomatType` value | Shaking |\n|---|---|---|\n| C6000 | `CytomatType.C6000` | yes |\n| C6002 | `CytomatType.C6002` | yes |\n| C2C 425 | `CytomatType.C2C_425` | yes |\n| C2C 450 Shake | `CytomatType.C2C_450_SHAKE` | yes |\n| C5C | `CytomatType.C5C` | no |\n\nCapabilities:\n\n- [Automated retrieval](../../capabilities/automated-retrieval) -- store and fetch plates by slot\n- [Temperature control](../../capabilities/temperature-control) -- read incubation temperature (set via device UI)\n- [Humidity control](../../capabilities/humidity-control) -- read humidity (set via device UI)\n- [Shaking](../../capabilities/shaking) -- integrated shakers (all models except C5C)\n\nA {class}`~pylabrobot.thermo_fisher.cytomat.chatterbox.CytomatChatterbox` backend is available for testing without hardware.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "s2n7ohfhcgr", + "source": "## Setup\n\nCreate a {class}`~pylabrobot.thermo_fisher.cytomat.backend.CytomatBackend` with the model type and serial port, configure racks, and build the {class}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat` resource.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xtwigozlk9g", + "source": "from pylabrobot.thermo_fisher.cytomat import Cytomat, CytomatBackend, CytomatType\nfrom pylabrobot.thermo_fisher.cytomat.racks import cytomat_rack_9mm_51\nfrom pylabrobot.resources import Coordinate\n\nbackend = CytomatBackend(model=CytomatType.C6000, port=\"/dev/ttyUSB0\") # replace with your port\n\nrack = cytomat_rack_9mm_51(\"rack_A\")\ncytomat = Cytomat(\n name=\"cytomat\",\n driver=backend,\n racks=[rack],\n loading_tray_location=Coordinate(0, 0, 0),\n size_x=860,\n size_y=550,\n size_z=900,\n)\n\nawait cytomat.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xhhjpsz67", + "source": "Many rack configurations are available in `pylabrobot.thermo_fisher.cytomat.racks` -- for example `cytomat_rack_10mm_47`, `cytomat_rack_17mm_28`, `cytomat_rack_23mm_21`, etc. Pick the one that matches your physical rack pitch and slot count.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "snc2ihvinf", + "source": "## Storing a plate\n\nPlace a plate on the loading tray and call {meth}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat.take_in_plate` to move it into storage. You can specify `\"smallest\"` (smallest free site that fits), `\"random\"`, or an explicit site. See [Automated Retrieval](../../capabilities/automated-retrieval) for the full capability API.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7mnnfp1pup6", + "source": "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n\nplate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\ncytomat.loading_tray.assign_child_resource(plate)\n\nawait cytomat.take_in_plate(\"smallest\") # choose the smallest free site\n\n# other options:\n# await cytomat.take_in_plate(\"random\") # random free site\n# await cytomat.take_in_plate(rack[3]) # store at rack position 3", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "jtiua5orv2m", + "source": "## Retrieving a plate\n\nUse {meth}`~pylabrobot.thermo_fisher.cytomat.cytomat.Cytomat.fetch_plate_to_loading_tray` to move a plate from storage back to the loading tray.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7o3qh6ahz29", + "source": "await cytomat.fetch_plate_to_loading_tray(\"my_plate\")\nretrieved = cytomat.loading_tray.resource", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "41utsk0m41z", + "source": "## Temperature and humidity monitoring\n\nThe Cytomat exposes read-only {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` capabilities. Temperature and humidity set-points are configured on the device UI -- PLR can only query current values. See [Temperature Control](../../capabilities/temperature-control) and [Humidity Control](../../capabilities/humidity-control) for the full capability APIs.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "adnrvbek88r", + "source": "current_temp = await cytomat.tc.request_temperature()\ncurrent_humidity = await cytomat.humidity.request_humidity()\nprint(f\"Temperature: {current_temp:.1f} C, Humidity: {current_humidity:.1f} %\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "rgpq1fg6di", + "source": "## Shaking\n\nAll models except C5C have integrated shakers exposed as a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`. See [Shaking](../../capabilities/shaking) for the full capability API.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "iivm5zxiq1", + "source": "# Start shaking at 500 rpm\nawait cytomat.shaker.shake(speed=500)\n\n# Stop shaking\nawait cytomat.shaker.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "c2o092wjri", + "source": "## Low-level backend operations\n\nThe backend exposes additional device-specific commands for door control, swap station queries, and direct storage-to-transfer movements:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "0q2nhyle9gl", + "source": "# Door control\nawait backend.open_door()\nawait backend.close_door()\n\n# Query incubation conditions (returns nominal and actual values)\nco2 = await backend.request_co2()\no2 = await backend.request_o2()\nprint(f\"CO2: nominal={co2.nominal_value}, actual={co2.actual_value}\")\nprint(f\"O2: nominal={o2.nominal_value}, actual={o2.actual_value}\")\n\n# Wait for the transfer station to become occupied\nawait backend.wait_for_transfer_station(occupied=True)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "9kgwkc4c97e", + "source": "## Storage summary\n\nCall `summary()` on the Cytomat to get a table showing which slots are occupied:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6ur6evj2pt", + "source": "print(cytomat.summary())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "tho63ur3fw", + "source": "## Legacy Heraeus backend\n\nFor older Heraeus-era Cytomats, use {class}`~pylabrobot.thermo_fisher.cytomat.heraeus_backend.HeraeusCytomatBackend` instead. It speaks a PLC-based serial protocol but exposes the same capabilities:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "t68os0tzwbo", + "source": "from pylabrobot.thermo_fisher.cytomat import HeraeusCytomatBackend\n\nheraeus_backend = HeraeusCytomatBackend(port=\"/dev/ttyUSB1\") # replace with your port\n\ncytomat_legacy = Cytomat(\n name=\"cytomat_legacy\",\n driver=heraeus_backend,\n racks=[cytomat_rack_9mm_51(\"rack_B\")],\n loading_tray_location=Coordinate(0, 0, 0),\n size_x=860,\n size_y=550,\n size_z=900,\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "n3b2bdc82d", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "k03tjfqtoy", + "source": "await cytomat.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/thermo_fisher/index.md b/docs/user_guide/thermo_fisher/index.md new file mode 100644 index 00000000000..b427887759c --- /dev/null +++ b/docs/user_guide/thermo_fisher/index.md @@ -0,0 +1,8 @@ +# Thermo Fisher + +```{toctree} +:maxdepth: 1 + +cytomat/hello-world +multidrop_combi/hello-world +``` diff --git a/docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb b/docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb new file mode 100644 index 00000000000..cf2dc745b26 --- /dev/null +++ b/docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb @@ -0,0 +1,248 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": "# Thermo Scientific Multidrop Combi\n\nThe Multidrop Combi is a peristaltic pump reagent dispenser for bulk dispensing into 96-, 384-, and 1536-well plates. It communicates via RS232/USB serial at 9600 baud.\n\nPLR exposes it as a {class}`~pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi.MultidropCombi` device with a {class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic8.PeristalticDispensing8` capability." + }, + { + "cell_type": "markdown", + "id": "e5f6g7h8", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "i9j0k1l2", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.thermo_fisher.multidrop_combi import MultidropCombi\n", + "\n", + "md = MultidropCombi(port=\"/dev/ttyUSB0\") # replace with your port\n", + "await md.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "m3n4o5p6", + "metadata": {}, + "source": [ + "On connect, the driver enters remote control mode and retrieves instrument info:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "q7r8s9t0", + "metadata": {}, + "outputs": [], + "source": "info = md.driver.get_version()\nprint(f\"{info['instrument_name']} FW {info['firmware_version']} SN {info['serial_number']}\")" + }, + { + "cell_type": "markdown", + "id": "u1v2w3x4", + "metadata": {}, + "source": [ + "## Plate configuration\n", + "\n", + "The Multidrop has 10 factory plate types (indexed 0--9). Use `plate_to_type_index` to map a PLR plate to the best-fit factory type, or `plate_to_pla_params` to define a custom plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "y5z6a7b8", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\nfrom pylabrobot.thermo_fisher.multidrop_combi import plate_to_type_index, plate_to_pla_params\n\nplate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n\n# Try factory type first\ntry:\n type_idx = plate_to_type_index(plate)\n print(f\"Factory plate type: {type_idx}\")\nexcept ValueError:\n # No factory match -- define a custom plate\n pla_params = plate_to_pla_params(plate)\n await md.peristaltic_dispenser.backend.define_plate(**pla_params)\n print(f\"Custom plate defined: {pla_params}\")" + }, + { + "cell_type": "markdown", + "id": "c9d0e1f2", + "metadata": {}, + "source": [ + "## Cassette type\n", + "\n", + "Set the cassette type before dispensing. The Multidrop supports Standard (0), Small (1), and two user-defined types (2--3)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "g3h4i5j6", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.thermo_fisher.multidrop_combi import CassetteType\n\nawait md.peristaltic_dispenser.backend.set_cassette_type(CassetteType.STANDARD)" + }, + { + "cell_type": "markdown", + "id": "k7l8m9n0", + "metadata": {}, + "source": "## Priming\n\nPrime the hoses before dispensing to fill the tubing with reagent. Use {class}`~pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8.MultidropCombiPeristalticDispensingBackend8.PrimeParams` for device-specific settings like prime mode." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "o1p2q3r4", + "metadata": {}, + "outputs": [], + "source": "await md.peristaltic_dispenser.prime(plate=plate, volume=500.0) # 500 uL" + }, + { + "cell_type": "markdown", + "id": "s5t6u7v8", + "metadata": {}, + "source": [ + "## Dispensing\n", + "\n", + "Dispense to the plate. Pass a `volumes` dict mapping 1-indexed column numbers to volumes in uL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w9x0y1z2", + "metadata": {}, + "outputs": [], + "source": "# 10 uL to all 12 columns\nawait md.peristaltic_dispenser.dispense(\n plate=plate,\n volumes={col: 10.0 for col in range(1, 13)},\n)" + }, + { + "cell_type": "markdown", + "id": "a3b4c5d6", + "metadata": {}, + "source": "Use {class}`~pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8.MultidropCombiPeristalticDispensingBackend8.DispenseParams` for device-specific settings like plate type, dispensing height, pump speed, and dispensing order:" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7f8g9h0", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.thermo_fisher.multidrop_combi import (\n MultidropCombiPeristalticDispensingBackend8,\n DispensingOrder,\n)\n\nawait md.peristaltic_dispenser.dispense(\n plate=plate,\n volumes={col: 25.0 for col in range(1, 13)},\n backend_params=MultidropCombiPeristalticDispensingBackend8.DispenseParams(\n plate_type=0,\n dispensing_height=2000,\n pump_speed=50,\n dispensing_order=DispensingOrder.COLUMN_WISE,\n ),\n)" + }, + { + "cell_type": "markdown", + "id": "i1j2k3l4", + "metadata": {}, + "source": [ + "Different volumes per column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m5n6o7p8", + "metadata": {}, + "outputs": [], + "source": "await md.peristaltic_dispenser.dispense(\n plate=plate,\n volumes={1: 10.0, 2: 20.0, 3: 30.0},\n)" + }, + { + "cell_type": "markdown", + "id": "q9r0s1t2", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "The Multidrop has a built-in plate shaker. Shake is a blocking command that returns when done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "u3v4w5x6", + "metadata": {}, + "outputs": [], + "source": "await md.peristaltic_dispenser.backend.shake(time=3.0, distance=2, speed=10) # 3s, 2mm, 10Hz" + }, + { + "cell_type": "markdown", + "id": "y7z8a9b0", + "metadata": {}, + "source": "## Purging\n\nPurge (empty) the hoses after dispensing to clear the tubing. Use {class}`~pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8.MultidropCombiPeristalticDispensingBackend8.PurgeParams` for device-specific settings like empty mode." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1d2e3f4", + "metadata": {}, + "outputs": [], + "source": "await md.peristaltic_dispenser.purge(plate=plate, volume=500.0)" + }, + { + "cell_type": "markdown", + "id": "g5h6i7j8", + "metadata": {}, + "source": [ + "## Moving the plate out\n", + "\n", + "Eject the plate to the loading position." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "k9l0m1n2", + "metadata": {}, + "outputs": [], + "source": "await md.peristaltic_dispenser.backend.move_plate_out()" + }, + { + "cell_type": "markdown", + "id": "o3p4q5r6", + "metadata": {}, + "source": "## Queries\n\nQuery instrument parameters and error logs via {class}`~pylabrobot.thermo_fisher.multidrop_combi.driver.MultidropCombiDriver`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s7t8u9v0", + "metadata": {}, + "outputs": [], + "source": "params = await md.driver.report_parameters()\nfor line in params[:5]:\n print(line)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w1x2y3z4", + "metadata": {}, + "outputs": [], + "source": "errors = await md.driver.read_error_log()\nprint(errors)" + }, + { + "cell_type": "markdown", + "id": "a5b6c7d8", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9f0g1h2", + "metadata": {}, + "outputs": [], + "source": [ + "await md.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file 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..ee8ae92a67c --- /dev/null +++ b/docs/user_guide/ufactory/xarm6/hello-world.ipynb @@ -0,0 +1,245 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "xarm6-intro", + "source": "# UFACTORY xArm 6\n\nThe [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- [Arms](../../capabilities/arms) (Cartesian and joint movement, pick/place, freedrive teaching)\n\nThe 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 |", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "xarm6-setup-header", + "source": "## Setup\n\nBuild a driver (IP, optional TCP offset/load), wrap it in `XArm6`, and call `setup()`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-setup-code", + "source": "from pylabrobot.ufactory.xarm6 import XArm6, XArm6Driver\n\ndriver = XArm6Driver(\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)\nxarm = XArm6(driver=driver)\nawait xarm.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-setup-note", + "source": "`setup()` connects to the controller, clears any pending errors, enables motion, and initializes the bio-gripper. To skip the gripper init, pass `backend_params=XArm6Driver.SetupParams(skip_gripper_init=True)` to `setup()`.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "xarm6-arm-header", + "source": "## Arm capabilities\n\nThe xArm 6 exposes an {class}`~pylabrobot.capabilities.arms.articulated_arm.ArticulatedArm` on `xarm.arm`, backed by {class}`~pylabrobot.ufactory.xarm6.backend.XArm6ArmBackend`. For the full arm API, see [Arms](../../capabilities/arms).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "xarm6-gripper-header", + "source": "### Gripper control\n\nGripper widths are in millimeters. The bio-gripper range is 71 mm (fully closed) to 150 mm (fully open).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-gripper-code", + "source": "await xarm.arm.open_gripper(gripper_width=150)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "a730d6d4", + "source": "await xarm.arm.close_gripper(gripper_width=71)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "f46f6337", + "source": "await xarm.arm.is_gripper_closed()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-cartesian-header", + "source": "### Cartesian movement\n\nMove the arm to a Cartesian location with a full 3D rotation. Use {class}`~pylabrobot.ufactory.xarm6.backend.XArm6ArmBackend.CartesianMoveParams` to override the default speed and acceleration.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-cartesian-code", + "source": "from pylabrobot.resources import Coordinate\nfrom pylabrobot.resources.rotation import Rotation\nfrom pylabrobot.ufactory.xarm6 import XArm6ArmBackend\n\nawait xarm.arm.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 backend_params=XArm6ArmBackend.CartesianMoveParams(speed=150, mvacc=2500),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-joint-header", + "source": "### Joint movement\n\nMove in joint space using the {class}`~pylabrobot.ufactory.xarm6.joints.XArm6Axis` enum and {class}`~pylabrobot.ufactory.xarm6.backend.XArm6ArmBackend.JointMoveParams`:\n\n```{warning}\nMoving 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```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-joint-code", + "source": "from pylabrobot.ufactory.xarm6 import XArm6Axis\n\nposition = {\n XArm6Axis.BASE_ROTATION: 0.0,\n XArm6Axis.SHOULDER: -30.0,\n XArm6Axis.ELBOW: -60.0,\n XArm6Axis.WRIST_ROLL: 0.0,\n XArm6Axis.WRIST_PITCH: 90.0,\n XArm6Axis.WRIST_YAW: 0.0,\n}\nawait xarm.arm.backend.move_to_joint_position(\n position,\n backend_params=XArm6ArmBackend.JointMoveParams(speed=40),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-query-header", + "source": "### Querying position\n\nGet the current joint angles or Cartesian end-effector pose:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-query-joints", + "source": "await xarm.arm.backend.request_joint_position()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "xarm6-query-cart", + "source": "await xarm.arm.backend.request_gripper_location()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-pick-place-header", + "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.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-pick-place-code", + "source": "pick = Coordinate(x=300, y=100, z=50)\nplace = Coordinate(x=300, y=-100, z=50)\nabove = Coordinate(x=0, y=0, z=80)\ndown = Rotation(x=180, y=0, z=0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "8965a469", + "source": "Approach the pick position from 80 mm above, descend, close the gripper, and retract:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "66890ff1", + "source": "await xarm.arm.move_to_location(pick + above, down)\nawait xarm.arm.pick_up_at_location(location=pick, rotation=down, resource_width=80)\nawait xarm.arm.move_to_location(pick + above, down)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "07782575", + "source": "Travel to the place position, descend, release, and retract:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "143c9c74", + "source": "await xarm.arm.move_to_location(place + above, down)\nawait xarm.arm.drop_at_location(location=place, rotation=down)\nawait xarm.arm.move_to_location(place + above, down)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-freedrive-header", + "source": "### Freedrive (teaching mode)\n\nEnter 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.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-freedrive-code", + "source": "await xarm.arm.backend.start_freedrive_mode(free_axes=[0])", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "d3d63cd0", + "source": "# Manually guide the arm to the desired pose, then read it:\ntaught = await xarm.arm.backend.request_gripper_location()\nprint(taught)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "c4c843d4", + "source": "await xarm.arm.backend.stop_freedrive_mode()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-misc-header", + "source": "### Park, halt, and error recovery\n\nPark the arm (SDK home by default, or a user-supplied `park_location`), emergency-stop all motion, or clear an error state:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-misc-code", + "source": "await xarm.arm.backend.park()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "bec2f90d", + "source": "await xarm.arm.backend.halt()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "a2dae38b", + "source": "await xarm.driver.clear_errors()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "xarm6-teardown-header", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xarm6-teardown-code", + "source": "await xarm.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/migration-guide-for-claude.md b/migration-guide-for-claude.md new file mode 100644 index 00000000000..d29700340b7 --- /dev/null +++ b/migration-guide-for-claude.md @@ -0,0 +1,127 @@ +# Docs migration guide: legacy category-based to manufacturer-based layout + +## Background + +The PLR codebase organizes device code by manufacturer (e.g. `pylabrobot/agilent/biotek/el406/`), but the docs historically used category-based directories: + +- `docs/user_guide/00_liquid-handling/` +- `docs/user_guide/01_material-handling/` +- `docs/user_guide/02_analytical/` + +We are migrating docs to mirror the codebase: `docs/user_guide//...`. + +This file lives at the repo root (not inside `docs/`) so Sphinx doesn't complain about it not being in a toctree. + +## How to migrate a single device + +### 1. Create the new doc directory + +Mirror the code path. For example: +- Code at `pylabrobot/qinstruments/bioshake.py` -> docs at `docs/user_guide/qinstruments/bioshake/` +- Code at `pylabrobot/agilent/biotek/el406/` -> docs at `docs/user_guide/agilent/biotek/el406/` + +### 2. Write the notebook + +Create a `hello-world.ipynb` at the new location. The notebook should: + +- **Import from the new code path** (e.g. `from pylabrobot.qinstruments import BioShakeQ1`, not `from pylabrobot.heating_shaking import BioShake`). +- **Show device setup and teardown.** +- **Give brief demos of each capability** the device supports (shaking, temperature control, etc.) — just enough to show the device-specific API surface (factory functions, backend params, model-specific notes). +- **Link to the capability docs for full API details** rather than duplicating them. Use relative links like `[Shaking](../../capabilities/shaking)` and `[Temperature Control](../../capabilities/temperature-control)`. +- **Include a model table** if the device has multiple models/variants, with a "PLR Name" column showing the factory function or class name. +- **Add Sphinx cross-references for BackendParams classes** used in the notebook. In markdown cells, use `{class}\`~pylabrobot...\`` syntax so they link to the API docs. Every BackendParams class that appears in a code cell should be mentioned with a cross-reference in a nearby markdown cell. + +See `docs/user_guide/qinstruments/bioshake/hello-world.ipynb` as the reference example for structure, and `docs/user_guide/agilent/biotek/el406/hello-world.ipynb` for BackendParams cross-referencing. + +### 3. Wire up the toctree + +The Manufacturers section in `docs/user_guide/index.md` lists manufacturer-level indexes. Each manufacturer has an `index.md` that lists its devices. + +**When there's only one device under a level, skip the intermediate index and point directly to the notebook.** Only create an `index.md` when a level has multiple children. + +Current structure: + +``` +docs/user_guide/index.md (Manufacturers toctree) +├── agilent/index.md -> lists biotek/index +│ └── biotek/index.md -> lists el406/hello-world (no el406/index.md — only one item) +├── azenta/index.md -> lists a4s/hello-world, xpeel/hello-world +├── inheco/index.md -> lists cpac, incubator_shaker, odtc, scila, thermoshake +├── liconic/index.md -> lists stx/hello-world +├── mettler_toledo/index.md -> lists wxs205sdu/hello-world +└── qinstruments/index.md -> lists bioshake/hello-world +``` + +**Adding a device to an existing manufacturer:** add the notebook path to the manufacturer's `index.md` toctree. If the manufacturer previously pointed directly to a single notebook, you'll need to create an intermediate `index.md` now that there are multiple items. + +**Adding a new manufacturer:** create `/index.md` and add it to the Manufacturers toctree in `docs/user_guide/index.md`. + +### 4. Add to the API reference + +Each manufacturer needs an RST file in `docs/api/` (e.g. `pylabrobot.azenta.rst`) that documents the device classes, drivers, and backends via `autosummary`. If the manufacturer already has an RST file, just add the new device's classes. + +**For nested BackendParams classes** (e.g. `XPeelPeelerBackend.PeelParams`), autosummary can't handle them directly. Use `autoclass` directives instead: + +```rst +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + XPeelPeelerBackend + +.. autoclass:: pylabrobot.azenta.xpeel.XPeelPeelerBackend.PeelParams + :members: +``` + +The RST file must be listed in the `Manufacturers` toctree in `docs/api/pylabrobot.rst`. + +See `docs/api/pylabrobot.azenta.rst` as the reference example. + +### 5. Remove the old notebook from the legacy location + +Delete the old `.ipynb` file from `00_liquid-handling/`, `01_material-handling/`, or `02_analytical/`. + +### 6. Remove the entry from the legacy category toctree + +Remove the device's toctree entry from the parent page (e.g. `heating_shaking.md`, `plate-washing.md`). If a sub-section becomes empty after removal, delete the entire sub-section directory too. The goal is to eventually delete `00_liquid-handling/`, `01_material-handling/`, and `02_analytical/` entirely. + +Do NOT update other text/links in the legacy pages — just remove the toctree entry and the file. + +### 7. Do NOT touch `machines.md` + +`machines.md` is legacy and will be kept as-is. Don't update links there. + +### 8. Build and verify + +Run `make clean-docs && make docs` for a full build including API docs. Fix any warnings — the build uses `-W` so warnings are errors. (The only acceptable warning is nbformat's `MissingIDFieldWarning` about cell IDs, which is pre-existing.) + +## Rules + +- Use **relative links** between doc pages, not absolute `https://docs.pylabrobot.org/...` URLs. +- The directory structure under `docs/user_guide/` should mirror the package structure under `pylabrobot/`. +- Migrate one device at a time. Don't batch. +- When a device has capabilities (shaking, temperature control, etc.), link to the capability docs — don't duplicate the API walkthrough. +- Include a "PLR Name" column in model tables showing the factory function or class name users should import. +- Skip intermediate `index.md` files when a level has only one child — point directly to the notebook instead. +- Always add the device's classes/backends to the API reference RST files. +- Use `autoclass` (not `autosummary`) for nested `BackendParams` classes — autosummary can't resolve inner classes. + +## Completed migrations + +| Device | Old location | New location | +|--------|-------------|--------------| +| BioTek EL406 | `00_liquid-handling/plate-washing/biotek-el406.ipynb` | `agilent/biotek/el406/hello-world.ipynb` | +| QInstruments BioShake | `01_material-handling/heating_shaking/qinstruments.ipynb` | `qinstruments/bioshake/hello-world.ipynb` | +| Mettler Toledo WXS205SDU | `02_analytical/scales/mettler-toledo-WXS205SDU.ipynb` | `mettler_toledo/wxs205sdu/hello-world.ipynb` | +| Azenta a4S | `01_material-handling/sealers/a4s.ipynb` | `azenta/a4s/hello-world.ipynb` | +| Azenta XPeel | _(no old doc)_ | `azenta/xpeel/hello-world.ipynb` | +| Liconic STX | `01_material-handling/storage/liconic.ipynb` | `liconic/stx/hello-world.ipynb` | +| Inheco ThermoShake | `01_material-handling/heating_shaking/inheco.ipynb` | `inheco/thermoshake/hello-world.ipynb` | +| Inheco CPAC | `01_material-handling/temperature-controllers/inheco.ipynb` | `inheco/cpac/hello-world.ipynb` | +| Inheco SCILA | `01_material-handling/storage/inheco/scila.ipynb` | `inheco/scila/hello-world.ipynb` | +| Inheco Incubator Shaker | `01_material-handling/storage/inheco/incubator_shaker.ipynb` | `inheco/incubator_shaker/hello-world.ipynb` | +| Inheco ODTC | `01_material-handling/thermocycling/inheco-odtc.ipynb` | `inheco/odtc/hello-world.ipynb` | +| Thermo Fisher Multidrop Combi | _(new with codebase)_ | `thermo_fisher/multidrop_combi/hello-world.ipynb` | +| BioTek Cytation | `02_analytical/plate-reading/cytation.ipynb` | `agilent/biotek/cytation/hello-world.ipynb` | +| Thermo Fisher Cytomat | `01_material-handling/storage/cytomat.ipynb` | `thermo_fisher/cytomat/hello-world.ipynb` | diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py new file mode 100644 index 00000000000..47564b66b6b --- /dev/null +++ b/pylabrobot/agilent/__init__.py @@ -0,0 +1,15 @@ +from .biotek import ( + EL406, + BioTekBackend, + BioTekLoadingTrayBackend, + Cytation1, + Cytation5, + CytationImagingConfig, + CytationMicroscopyBackend, + EL406Driver, + EL406PlateWasher96Backend, + EL406ShakingBackend, + SynergyH1, + SynergyH1Backend, +) +from .vspin import Access2, Access2Driver, VSpin, VSpinCentrifugeBackend, VSpinDriver diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py new file mode 100644 index 00000000000..d6697e2ca7d --- /dev/null +++ b/pylabrobot/agilent/biotek/__init__.py @@ -0,0 +1,11 @@ +from .plate_readers import ( + BioTekBackend, + Cytation1, + Cytation5, + CytationImagingConfig, + CytationMicroscopyBackend, + SynergyH1, + SynergyH1Backend, +) +from .loading_tray_backend import BioTekLoadingTrayBackend +from .el406 import EL406, EL406Driver, EL406PlateWasher96Backend, EL406ShakingBackend diff --git a/pylabrobot/plate_reading/agilent/biotek_tests.py b/pylabrobot/agilent/biotek/biotek_tests.py similarity index 91% rename from pylabrobot/plate_reading/agilent/biotek_tests.py rename to pylabrobot/agilent/biotek/biotek_tests.py index d011901249f..35eace70517 100644 --- a/pylabrobot/plate_reading/agilent/biotek_tests.py +++ b/pylabrobot/agilent/biotek/biotek_tests.py @@ -10,7 +10,7 @@ pytest.importorskip("pylibftdi") -from pylabrobot.plate_reading.agilent.biotek_cytation_backend import CytationBackend +from pylabrobot.agilent.biotek.plate_readers.base import BioTekBackend from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb @@ -23,7 +23,7 @@ class TestCytation5Backend(unittest.IsolatedAsyncioTestCase): """Tests for the Cytation5Backend.""" async def asyncSetUp(self): - self.backend = CytationBackend(timeout=0.1) + self.backend = BioTekBackend(timeout=0.1) self.backend.io = unittest.mock.MagicMock() self.backend.io.setup = unittest.mock.AsyncMock() self.backend.io.stop = unittest.mock.AsyncMock() @@ -40,8 +40,9 @@ async def asyncSetUp(self): self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate") # Mock time.time() to control the timestamp in the results - self.mock_time = unittest.mock.patch("time.time", return_value=12345.6789).start() - self.addCleanup(self.mock_time.stop) + self._time_patcher = unittest.mock.patch("time.time", return_value=12345.6789) + self.mock_time = 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") @@ -57,9 +58,9 @@ async def test_setup(self): await self.backend.stop() assert self.backend.io.stop.called - async def test_get_serial_number(self): + async def test_request_serial_number(self): self.backend.io.read.side_effect = _byte_iter("\x0600000000 0000\x03") - assert await self.backend.get_serial_number() == "00000000" + 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"] @@ -72,9 +73,9 @@ async def test_close(self): await self.backend.close(plate=plate) self.backend.io.write.assert_called_with(b"A") - async def test_get_current_temperature(self): + async def test_request_current_temperature(self): self.backend.io.read.side_effect = _byte_iter("\x062360000\x03") - assert await self.backend.get_current_temperature() == 23.6 + assert await self.backend.request_current_temperature() == 23.6 async def test_read_absorbance(self): self.backend.io.read.side_effect = _byte_iter( @@ -192,17 +193,11 @@ async def test_read_absorbance(self): [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( - resp, - [ - { - "wavelength": 580, - "data": expected_data, - "temperature": 23.6, - "time": 12345.6789, - } - ], - ) + 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, 12345.6789) async def test_read_luminescence_partial(self): self.backend.io.read.side_effect = _byte_iter( @@ -233,7 +228,10 @@ async def test_read_luminescence_partial(self): 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, integration_time=0.4, plate=plate, wells=wells + focal_height=4.5, + plate=plate, + wells=wells, + backend_params=BioTekBackend.LuminescenceParams(integration_time=0.4), ) self.backend.io.write.assert_any_call(b"D") @@ -258,16 +256,10 @@ async def test_read_luminescence_partial(self): [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( - resp, - [ - { - "data": expected_data, - "temperature": 23.6, - "time": 12345.6789, - } - ], - ) + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, 12345.6789) async def test_read_fluorescence(self): self.backend.io.read.side_effect = _byte_iter( @@ -328,18 +320,12 @@ async def test_read_fluorescence(self): [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( - resp, - [ - { - "ex_wavelength": 485, - "em_wavelength": 528, - "data": expected_data, - "temperature": 23.6, - "time": 12345.6789, - } - ], - ) + 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, 12345.6789) async def test_parse_body_asterisks_as_nan(self): """Unmeasured wells return ******* which should be parsed as NaN.""" diff --git a/pylabrobot/agilent/biotek/el406/__init__.py b/pylabrobot/agilent/biotek/el406/__init__.py new file mode 100644 index 00000000000..4a901cfe2c1 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/__init__.py @@ -0,0 +1,6 @@ +from .driver import EL406Driver +from .el406 import EL406 +from .peristaltic_dispensing_backend8 import EL406PeristalticDispensingBackend8 +from .plate_washing_backend import EL406PlateWasher96Backend +from .shaking_backend import EL406ShakingBackend +from .syringe_dispensing_backend8 import EL406SyringeDispensingBackend8 diff --git a/pylabrobot/agilent/biotek/el406/architecture.md b/pylabrobot/agilent/biotek/el406/architecture.md new file mode 100644 index 00000000000..123931e9209 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/architecture.md @@ -0,0 +1,113 @@ +# BioTek EL406 — Architecture + +## Overview + +The EL406 plate washer has four subsystems: + +1. **Manifold** — aspirate, dispense, wash cycles, prime, auto-clean (vacuum-based) +2. **Syringe pumps** — precise dispense/prime via dual syringes (A/B) +3. **Peristaltic pumps** — continuous-flow dispense/prime/purge via cassettes +4. **Shaker** — plate shaking with soak periods + +Each subsystem maps to a separate capability in the new architecture. + +## Current migration status + +| Subsystem | Capability | Backend | Status | +|-----------|-----------|---------|--------| +| Manifold | `PlateWasher96` | `EL406PlateWasher96Backend` | Done | +| Syringe | TBD | TBD | Not started | +| Peristaltic | TBD (`PumpingCapability`?) | TBD | Not started | +| Shaker | `ShakingCapability` (existing) | TBD | Not started | + +## Class diagram + +``` +EL406 (Device, Resource) + ├── _driver: EL406Driver + │ ├── FTDI I/O (setup/stop, serial config) + │ ├── Command sending (send_framed_command, send_action_command, send_step_command) + │ ├── Polling (poll_device_state, wait_until_ready) + │ ├── Batch management (batch context manager, start_batch) + │ └── Device-level ops (reset, home_motors, pause, resume, abort, set_washer_manifold) + │ + ├── washer: PlateWasher96 + │ └── backend: EL406PlateWasher96Backend + │ ├── PlateWasher96Backend interface: aspirate, dispense, wash, prime + │ ├── Full manifold API: aspirate, dispense, + │ │ wash, prime, auto_clean + │ └── Command builders (_build_aspirate_command, _build_wash_composite_command, etc.) + │ + └── plate_holder: PlateHolder +``` + +## File layout + +``` +pylabrobot/agilent/biotek/el406/ +├── __init__.py # Exports EL406, EL406Driver, EL406PlateWasher96Backend +├── driver.py # EL406Driver — FTDI I/O, lifecycle, device-level ops +├── plate_washing_backend.py # EL406PlateWasher96Backend — manifold protocol encoding +├── el406.py # EL406 Device — wires driver + capabilities +└── architecture.md # This file +``` + +## Shared modules (in legacy, to be moved later) + +The driver and backend import utility modules that still live under the legacy path: + +- `legacy/.../protocol.py` — `build_framed_message()` wire framing +- `legacy/.../helpers.py` — `plate_to_wire_byte()`, plate defaults +- `legacy/.../enums.py` — `EL406WasherManifold`, `EL406Motor`, etc. +- `legacy/.../errors.py` — `EL406CommunicationError`, `EL406DeviceError` +- `legacy/.../error_codes.py` — error code lookup table + +These are protocol/hardware constants, not legacy API. They should eventually move to +`pylabrobot/agilent/biotek/el406/` once all subsystems are migrated. + +## Wire protocol + +- **Transport**: FTDI USB, 38400 baud, 8N2, no flow control +- **Framing**: 11-byte header (start marker, version, command LE16, constant, reserved, data length LE16, checksum LE16) + data +- **Flow**: Command → ACK (0x06) → response header + data. Step commands require STATUS_POLL (0x92) polling for completion. + +### Manifold command codes + +| Command | Code | Payload size | +|---------|------|-------------| +| Aspirate | 0xA5 | 22 bytes | +| Dispense | 0xA6 | 20 bytes | +| Wash | 0xA4 | 102 bytes | +| Prime | 0xA7 | 13 bytes | +| Auto-clean | 0xA8 | 8 bytes | + +## Usage + +```python +from pylabrobot.agilent.biotek.el406 import EL406 +from pylabrobot.resources import Plate + +el406 = EL406(name="washer") +await el406.setup() + +plate = Plate(...) # your plate resource + +# Simple API (via PlateWasher96) +await el406.washer.wash(plate, cycles=3, dispense_volume=300) +await el406.washer.aspirate(plate) +await el406.washer.dispense(plate, volume=200) + +# Full EL406 manifold API (via backend) +await el406.washer.backend.wash( + plate, cycles=5, buffer="B", dispense_flow_rate=9, + shake_duration=30, shake_intensity="Medium", +) +await el406.washer.backend.prime(plate, volume=10000, buffer="A") +await el406.washer.backend.auto_clean(plate, buffer="A", duration=120) + +# Device-level ops (via driver) +await el406._driver.reset() +await el406._driver.set_washer_manifold(EL406WasherManifold.TUBE_96_DUAL) + +await el406.stop() +``` diff --git a/pylabrobot/plate_washing/biotek/el406/communication.py b/pylabrobot/agilent/biotek/el406/driver.py similarity index 52% rename from pylabrobot/plate_washing/biotek/el406/communication.py rename to pylabrobot/agilent/biotek/el406/driver.py index df2be8f6ad4..7724e24c65e 100644 --- a/pylabrobot/plate_washing/biotek/el406/communication.py +++ b/pylabrobot/agilent/biotek/el406/driver.py @@ -1,22 +1,40 @@ -"""EL406 low-level communication methods. +"""EL406 Driver — owns FTDI I/O, connection lifecycle, and device-level operations. -This module contains the mixin class for low-level USB/FTDI communication -with the BioTek EL406 plate washer. +Protocol: 38400 baud, 8N2, no flow control, binary LE framing. """ from __future__ import annotations import asyncio +import enum import logging import time -from typing import TYPE_CHECKING, NamedTuple +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import NamedTuple, Optional, TypedDict, TypeVar +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver from pylabrobot.io.binary import Reader - +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Plate, Resource + +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__) + LONG_READ_TIMEOUT = 120.0 # seconds, for long operations (wash cycles can take >30s) STATE_INITIAL = 1 @@ -24,9 +42,6 @@ STATE_PAUSED = 3 STATE_STOPPED = 4 -if TYPE_CHECKING: - from pylabrobot.io.ftdi import FTDI - class DevicePollResult(NamedTuple): """Parsed result from a STATUS_POLL response.""" @@ -37,28 +52,147 @@ class DevicePollResult(NamedTuple): raw_response: bytes -logger = logging.getLogger(__name__) +class EL406Driver(Driver): + """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.). + """ -class EL406CommunicationMixin: - """Mixin providing low-level communication methods for the EL406. + def __init__( + self, + timeout: float = 15.0, + device_id: str | None = None, + ) -> None: + super().__init__() + self.timeout = timeout + self._device_id = device_id + self.io: FTDI | None = None + self._command_lock: asyncio.Lock | None = None + self._in_batch: bool = False + self._cached_plate: Plate | None = None + + @property + def plate(self) -> Plate: + """The plate currently assigned to the EL406. + + Set automatically when a plate is assigned to the device's plate_holder. - This mixin provides: - - Buffer purging - - Framed command sending - - Action command sending (with completion wait) - - Framed query sending - - Low-level byte reading + Raises: + RuntimeError: If no plate is assigned. + """ + if self._cached_plate is None: + raise RuntimeError( + "No plate is assigned to the EL406. " + "Assign a plate to el406.plate_holder before running commands." + ) + return self._cached_plate - Requires: - self.io: FTDI IO wrapper instance - self.timeout: Default timeout in seconds - self._command_lock: asyncio.Lock for command serialization - """ + def _on_plate_assigned(self, resource: Resource) -> None: + if isinstance(resource, Plate): + self._cached_plate = resource + + def _on_plate_unassigned(self, resource: Resource) -> None: + if isinstance(resource, Plate): + self._cached_plate = None + + @dataclass + class SetupParams(BackendParams): + """EL406-specific parameters for ``setup``. + + Args: + skip_reset: If True, skip the instrument reset step during setup. + """ + + skip_reset: bool = False + + async def setup(self, backend_params: Optional[BackendParams] = None) -> 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. - io: FTDI | None - timeout: float - _command_lock: asyncio.Lock | None + Raises: + RuntimeError: If pylibftdi is not installed or communication fails. + """ + if not isinstance(backend_params, EL406Driver.SetupParams): + backend_params = EL406Driver.SetupParams() + + self._command_lock = asyncio.Lock() + + logger.info("EL406Driver setting up") + logger.info(" Timeout: %.1f seconds", self.timeout) + + if self.io is None: + self.io = FTDI(human_readable_device_name="BioTek EL406", device_id=self._device_id) + 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() + self.io = None + 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 backend_params.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") + if self.io is not None: + await self.io.stop() + self.io = None + + def serialize(self) -> dict: + """Serialize driver configuration.""" + return { + **super().serialize(), + "timeout": self.timeout, + "device_id": self._device_id, + } + + # --------------------------------------------------------------------------- + # Low-level I/O + # --------------------------------------------------------------------------- async def _write_to_device(self, data: bytes) -> None: """Write bytes to the FTDI device, wrapping errors. @@ -167,42 +301,9 @@ async def _test_communication(self) -> None: init_response = await self._send_framed_command(init_state_cmd, timeout=self.timeout) logger.debug("INIT_STATE sent, response: %s", init_response.hex()) - 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()) + # --------------------------------------------------------------------------- + # Command sending + # --------------------------------------------------------------------------- async def _send_framed_command( self, @@ -420,6 +521,10 @@ async def _send_framed_query( 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. @@ -556,3 +661,330 @@ async def _send_step_command( 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: "EL406Driver.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 diff --git a/pylabrobot/agilent/biotek/el406/el406.py b/pylabrobot/agilent/biotek/el406/el406.py new file mode 100644 index 00000000000..99e9798a7b9 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/el406.py @@ -0,0 +1,83 @@ +"""BioTek EL406 plate washer device.""" + +from typing import Optional + +from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensing8 +from pylabrobot.capabilities.bulk_dispensers.syringe import SyringeDispensing8 +from pylabrobot.capabilities.plate_washing import PlateWasher96 +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource + +from .driver import EL406Driver +from .peristaltic_dispensing_backend8 import EL406PeristalticDispensingBackend8 +from .plate_washing_backend import EL406PlateWasher96Backend +from .shaking_backend import EL406ShakingBackend +from .syringe_dispensing_backend8 import EL406SyringeDispensingBackend8 + + +class EL406(Resource, Device): + """BioTek EL406 plate washer. + + Example: + >>> el406 = EL406(name="el406") + >>> await el406.setup() + >>> await el406.washer.wash(plate, cycles=3) + >>> await el406.stop() + """ + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + timeout: float = 15.0, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + driver = EL406Driver(timeout=timeout, device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="BioTek EL406", + ) + Device.__init__(self, driver=driver) + self.driver: EL406Driver = driver + + self.washer = PlateWasher96(backend=EL406PlateWasher96Backend(driver)) + self.shaker = Shaker(backend=EL406ShakingBackend(driver)) + self.syringe_dispenser = SyringeDispensing8(backend=EL406SyringeDispensingBackend8(driver)) + self.peristaltic_dispenser = PeristalticDispensing8(backend=EL406PeristalticDispensingBackend8(driver)) + self._capabilities = [self.washer, self.shaker, self.syringe_dispenser, self.peristaltic_dispenser] + + 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(), + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + self.plate_holder.register_did_assign_resource_callback(self._on_plate_assigned) + self.plate_holder.register_did_unassign_resource_callback(self._on_plate_unassigned) + + def _on_plate_assigned(self, resource: Resource) -> None: + if isinstance(resource, Plate): + self.driver._cached_plate = resource + self.washer.plate = resource + self.syringe_dispenser.plate = resource + self.peristaltic_dispenser.plate = resource + + def _on_plate_unassigned(self, resource: Resource) -> None: + if isinstance(resource, Plate): + self.driver._cached_plate = None + self.washer.plate = None + self.syringe_dispenser.plate = None + self.peristaltic_dispenser.plate = None + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} 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/plate_washing/biotek/el406/steps/_peristaltic.py b/pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend8.py similarity index 57% rename from pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py rename to pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend8.py index 6f06d4eb883..23b2414d932 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py +++ b/pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend8.py @@ -7,20 +7,23 @@ from __future__ import annotations import logging -from typing import Literal +from dataclasses import dataclass +from typing import Dict, Literal, Optional +from pylabrobot.capabilities.bulk_dispensers.peristaltic.backend8 import PeristalticDispensingBackend8 +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.io.binary import Writer from pylabrobot.resources import Plate -from ..helpers import ( +from .driver import EL406Driver +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 -from ._base import EL406StepsBaseMixin +from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask logger = logging.getLogger(__name__) @@ -39,7 +42,7 @@ def cassette_to_byte(cassette: Cassette) -> int: def encode_quadrant_mask_inverted( - rows: list[int] | None, + rows: Optional[list[int]], num_row_groups: int = 4, ) -> int: """Encode row/quadrant selection as inverted bitmask. @@ -78,268 +81,277 @@ def validate_peristaltic_flow_rate(flow_rate: PeristalticFlowRate) -> None: ) -class EL406PeristalticStepsMixin(EL406StepsBaseMixin): - """Mixin for peristaltic pump step operations.""" +class EL406PeristalticDispensingBackend8(PeristalticDispensingBackend8): + """Peristaltic dispensing backend for the BioTek EL406.""" - def _validate_peristaltic_well_selection( - self, - plate: Plate, - columns: list[int] | None, - rows: list[int] | None, - ) -> list[int] | None: - """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}).") + @dataclass + class DispenseParams(BackendParams): + """Parameters for peristaltic dispense. - 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}).") + Attributes: + 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). Default depends on plate type: + 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 (default 2). + 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. + """ - return columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) + 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 + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver - def _validate_peristaltic_dispense_params( + async def dispense( self, plate: Plate, - volume: float, - flow_rate: PeristalticFlowRate, - offset_x: int, - offset_y: int, - offset_z: int | None, - pre_dispense_volume: float, - columns: list[int] | None, - rows: list[int] | None, - ) -> tuple[int, int, list[int] | None]: - """Validate peristaltic dispense parameters and resolve defaults. - - Returns: - (offset_z, flow_rate_enum, column_mask) + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + + # 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._driver.batch(): + for vol, cols in groups: + await self._peristaltic_dispense(plate, volume=vol, columns=cols, params=backend_params) + + @dataclass + class PrimeParams(BackendParams): + """Parameters for peristaltic prime and purge. + + Attributes: + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). """ - 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 <= 125: - raise ValueError(f"Peri-pump dispense X-axis offset must be -125..125, got {offset_x}") - if not -40 <= offset_y <= 40: - raise ValueError(f"Peri-pump dispense Y-axis offset must be -40..40, got {offset_y}") - - if offset_z is None: - offset_z = plate_default_z(plate) - if not 1 <= offset_z <= 1500: - raise ValueError(f"Peri-pump dispense Z-axis offset must be 1..1500, got {offset_z}") - if pre_dispense_volume < 0: - raise ValueError(f"pre_dispense_volume must be non-negative, got {pre_dispense_volume}") + flow_rate: PeristalticFlowRate = "High" + cassette: Cassette = "Any" - column_mask = self._validate_peristaltic_well_selection(plate, columns, rows) - - return (offset_z, PERISTALTIC_FLOW_RATE_MAP[flow_rate], column_mask) - - async def peristaltic_prime( + async def prime( self, plate: Plate, - volume: float | None = None, - duration: int | None = None, - flow_rate: PeristalticFlowRate = "High", - cassette: Cassette = "Any", + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, ) -> None: - """Prime the peristaltic fluid lines. + """Prime the peristaltic pump fluid lines. - Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. - If neither is given, defaults to volume mode with 1000 uL. + 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 ``manifold_prime()`` for buffer-specific priming. + Note: Peristaltic prime has no buffer selection. Use the manifold + ``prime()`` on :class:`EL406PlateWasher96Backend` for buffer-specific priming. Args: plate: PLR Plate resource. - volume: Volume to prime in microliters. - duration: Fixed duration in seconds (alternative to volume). - flow_rate: Flow rate ("Low", "Medium", or "High"). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + volume: Prime volume in uL (1-3000). Mutually exclusive with duration. + duration: Fixed prime duration in seconds (1-300). Mutually exclusive + with volume. + backend_params: :class:`PrimeParams` with flow_rate and cassette settings. Raises: - ValueError: If both volume and duration are specified, or if parameters are invalid. + ValueError: If parameters are invalid or both volume and duration given. """ + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + p = backend_params + 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") - prime_volume = 0.0 - prime_duration = duration + 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)") - prime_volume = volume - prime_duration = 0 - - validate_peristaltic_flow_rate(flow_rate) + wire_volume, wire_duration = volume, 0 - logger.info( - "Peristaltic prime: %.1f uL, flow rate %s, cassette %s", prime_volume, flow_rate, cassette - ) + validate_peristaltic_flow_rate(p.flow_rate) + logger.info("Peristaltic prime: %.1f uL, flow rate %s, cassette %s", + wire_volume, p.flow_rate, p.cassette) data = self._build_peristaltic_prime_command( - plate=plate, - volume=prime_volume, - duration=prime_duration, - flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], - reverse=True, - cassette=cassette, - pump=1, + plate=plate, volume=wire_volume, duration=wire_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[p.flow_rate], + reverse=True, cassette=p.cassette, pump=1, ) framed_command = build_framed_message(command=0x90, data=data) - # Timeout: duration (if specified) + buffer for volume-based priming - prime_timeout = self.timeout + prime_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=prime_timeout) + async with self._driver.batch(): + await self._driver._send_step_command( + framed_command, timeout=self._driver.timeout + wire_duration + 30 + ) - async def peristaltic_dispense( + async def purge( self, plate: Plate, - volume: float, - flow_rate: PeristalticFlowRate = "High", - offset_x: int = 0, - offset_y: int = 0, - offset_z: int | None = None, - pre_dispense_volume: float = 10.0, - num_pre_dispenses: int = 2, - cassette: Cassette = "Any", - columns: list[int] | None = None, - rows: list[int] | None = None, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, ) -> None: - """Dispense liquid using the peristaltic pump. + """Purge the peristaltic pump fluid lines. - Args: - plate: PLR Plate resource. - volume: Dispense volume in microliters (1-3000). - flow_rate: Flow rate ("Low", "Medium", or "High"). - offset_x: X offset in 0.1mm units (-125 to 125). - offset_y: Y offset in 0.1mm units (-40 to 40). - offset_z: Z offset in 0.1mm units (1-1500). Default depends on plate type: - 336 for 96/384-well, 254 for 1536-well. - pre_dispense_volume: Pre-dispense volume in uL (0 to disable). - num_pre_dispenses: Number of pre-dispenses (default 2). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - columns: List of 1-indexed column numbers to dispense to, or None for all. - For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. - 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. - - Raises: - ValueError: If parameters are invalid. - """ - offset_z, flow_rate_enum, column_mask = self._validate_peristaltic_dispense_params( - plate=plate, - volume=volume, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - columns=columns, - 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, - offset_y=offset_y, - offset_z=offset_z, - 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.batch(plate): - await self._send_step_command(framed_command) - - async def peristaltic_purge( - self, - plate: Plate, - volume: float | None = None, - duration: int | None = None, - flow_rate: PeristalticFlowRate = "High", - cassette: Cassette = "Any", - ) -> None: - """Purge the fluid lines using the peristaltic pump. - - Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. - - PERISTALTIC_PURGE uses the same data format as PERISTALTIC_PRIME - (both send identical data bytes). + 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. Args: plate: PLR Plate resource. - volume: Purge volume in microliters. - duration: Fixed duration in seconds (alternative to volume). - flow_rate: Flow rate ("Low", "Medium", or "High"). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + volume: Purge volume in uL (1-3000). Mutually exclusive with duration. + duration: Fixed purge duration in seconds (1-300). Mutually exclusive + with volume. + backend_params: :class:`PrimeParams` with flow_rate and cassette settings. Raises: - ValueError: If both volume and duration are specified, or if neither is given. + ValueError: If parameters are invalid, both are given, or neither is given. """ + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + p = backend_params + 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") - purge_volume = 0.0 - purge_duration = duration + wire_volume, wire_duration = 0.0, duration else: - assert volume is not None # guaranteed by the mutual-exclusion check above + assert volume is not None if not 1 <= volume <= 3000: raise ValueError("volume must be 1-3000 uL (GUI limit)") - purge_volume = volume - purge_duration = 0 - - validate_peristaltic_flow_rate(flow_rate) + wire_volume, wire_duration = volume, 0 - logger.info( - "Peristaltic purge: %.1f uL, flow rate %s, cassette %s", - purge_volume, - flow_rate, - cassette, - ) + validate_peristaltic_flow_rate(p.flow_rate) + logger.info("Peristaltic purge: %.1f uL, flow rate %s, cassette %s", + wire_volume, p.flow_rate, p.cassette) - # Reuse peristaltic_prime builder since data format is identical data = self._build_peristaltic_prime_command( - plate=plate, - volume=purge_volume, - duration=purge_duration, - flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], - reverse=True, - cassette=cassette, - pump=1, + plate=plate, volume=wire_volume, duration=wire_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[p.flow_rate], + reverse=True, cassette=p.cassette, pump=1, ) framed_command = build_framed_message(command=0x91, data=data) - # Timeout: duration (if specified) + buffer for volume-based purging - purge_timeout = self.timeout + purge_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=purge_timeout) + async with self._driver.batch(): + await self._driver._send_step_command( + framed_command, timeout=self._driver.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]], + p: "EL406PeristalticDispensingBackend8.DispenseParams", + ) -> 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(p.offset_x * 10) + offset_y_steps = round(p.offset_y * 10) + offset_z_steps = round(p.offset_z * 10) if p.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(p.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 p.pre_dispense_volume < 0: + raise ValueError(f"pre_dispense_volume must be non-negative, got {p.pre_dispense_volume}") + + column_mask = self._validate_well_selection(plate, columns, p.rows) + flow_rate_enum = PERISTALTIC_FLOW_RATE_MAP[p.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, + params: Optional[DispenseParams] = 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. + params: Backend-specific parameters (flow rate, offsets, cassette). + """ + if params is None: + params = self.DispenseParams() + p = params + + offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask = ( + self._validate_dispense_params(plate, volume, columns, p) + ) + + logger.info("Peristaltic dispense: %.1f uL, flow rate %s, cassette %s", + volume, p.flow_rate, p.cassette) + + data = self._build_peristaltic_dispense_command( + plate=plate, volume=volume, flow_rate=flow_rate_enum, cassette=p.cassette, + offset_x=offset_x_steps, offset_y=offset_y_steps, offset_z=offset_z_steps, + pre_dispense_volume=p.pre_dispense_volume, num_pre_dispenses=p.num_pre_dispenses, + column_mask=column_mask, rows=p.rows, pump=1, + ) + framed_command = build_framed_message(command=0x8F, data=data) + async with self._driver.batch(): + await self._driver._send_step_command(framed_command) # ========================================================================= # COMMAND BUILDERS @@ -404,8 +416,8 @@ def _build_peristaltic_dispense_command( offset_z: int = 336, pre_dispense_volume: float = 0.0, num_pre_dispenses: int = 2, - column_mask: list[int] | None = None, - rows: list[int] | None = None, + column_mask: Optional[list[int]] = None, + rows: Optional[list[int]] = None, pump: int = 1, ) -> bytes: """Build peristaltic dispense command bytes. diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py b/pylabrobot/agilent/biotek/el406/plate_washing_backend.py similarity index 58% rename from pylabrobot/plate_washing/biotek/el406/steps/_manifold.py rename to pylabrobot/agilent/biotek/el406/plate_washing_backend.py index 434e62409d6..78ac459a238 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py +++ b/pylabrobot/agilent/biotek/el406/plate_washing_backend.py @@ -1,21 +1,41 @@ """EL406 manifold step methods. -Provides manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, -and manifold_auto_clean operations plus their corresponding command builders. +Provides aspirate, dispense, wash, prime, +and auto_clean operations plus their corresponding command builders. """ from __future__ import annotations import logging -from typing import Literal +from dataclasses import dataclass +from typing import Literal, Optional +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_washing.backend import PlateWasher96Backend 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 -from ._base import EL406StepsBaseMixin -from ._shake import INTENSITY_TO_BYTE, Intensity, validate_intensity +from .driver import EL406Driver +from .helpers import plate_defaults, plate_to_wire_byte +from .protocol import build_framed_message + +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__) @@ -79,8 +99,195 @@ def validate_travel_rate(rate: int) -> None: raise ValueError(f"travel_rate must be 1-9, got {rate}") -class EL406ManifoldStepsMixin(EL406StepsBaseMixin): - """Mixin for manifold step operations.""" +class EL406PlateWasher96Backend(PlateWasher96Backend): + """Manifold plate washing backend for the BioTek EL406. + + Implements the abstract PlateWasher96Backend interface and also exposes the + full EL406-specific manifold API for users who need fine-grained control. + """ + + @dataclass + class WashParams(BackendParams): + """Parameters for manifold wash. + + Attributes: + buffer: Buffer valve selection (A, B, C, D). Default A. + dispense_flow_rate: Flow rate for dispensing (1-9). Default 7. + dispense_x: Dispense X offset in steps (-60 to +60). Default 0. + dispense_y: Dispense Y offset in steps (-40 to +40). Default 0. + dispense_z: Z offset for dispense in 0.1mm units (1-210). Default None + (plate-type-aware: 121 for 96-well, 120 for 384-well, etc.). + aspirate_travel_rate: Travel rate for aspiration (1-9). Default 3. + aspirate_z: Z offset for aspirate in 0.1mm units (1-210). Default None + (plate-type-aware: 29 for 96-well, 22 for 384-well, etc.). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Default 9. + Controls how fast the pre-dispense is delivered. + aspirate_delay: Post-aspirate delay in seconds (0-65.535). Default 0. + Wire resolution: 1 ms. + aspirate_x: Aspirate X offset in steps (-60 to +60). Default 0. + aspirate_y: Aspirate Y offset in steps (-40 to +40). Default 0. + final_aspirate: Enable final aspirate after last cycle. Default True. + Encoded in header config flags byte [2]. + final_aspirate_z: Z offset for final aspirate (1-210). Default None + (inherits from aspirate_z). Independent from primary aspirate Z. + final_aspirate_x: X offset for final aspirate (-60 to +60). Default 0. + final_aspirate_y: Y offset for final aspirate (-40 to +40). Default 0. + final_aspirate_delay: Post-aspirate delay for final aspirate in + seconds (0-65.535). Default 0. Wire resolution: 1 ms. + pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, + 25-3000 when enabled). Default 0.0. + vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, + 0-3000 when enabled). Cell wash operations only. Default 0.0. + soak_duration: Soak duration in seconds (0 to disable, 0-3599). Default 0. + shake_duration: Shake duration in seconds (0 to disable, 0-3599). Default 0. + shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + Default "Medium". + secondary_aspirate: Enable secondary aspirate for primary (between-cycle) + aspirate. Default False. + secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). + Default None (plate-type-aware, same as aspirate_z default). + secondary_x: Secondary aspirate X offset (-60 to +60). Default 0. + secondary_y: Secondary aspirate Y offset (-40 to +40). Default 0. + final_secondary_aspirate: Enable secondary aspirate for final aspirate. + Default False. + final_secondary_z: Z offset for final secondary aspirate (1-210). + Default None (plate-type-aware, same as aspirate_z default). + final_secondary_x: X offset for final secondary aspirate (-60 to +60). + Default 0. + final_secondary_y: Y offset for final secondary aspirate (-40 to +40). + Default 0. + bottom_wash: Enable bottom wash. Default False. Encoded in header[1]. + bottom_wash_volume: Bottom wash volume in uL (25-3000). Default 0.0. + bottom_wash_flow_rate: Bottom wash flow rate (3-11). Default 5. + pre_dispense_between_cycles_volume: Pre-dispense volume between wash + cycles in uL (0 to disable, 25-3000 when enabled). Default 0.0. + pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between + cycles (3-11). Default 9. + wash_format: Wash format ("Plate" or "Sector"). Default "Plate". + 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). Default None (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. + Default False. Same as in standalone shake interface. + Encoded at wire [87] (shake/soak section byte 0). + """ + + 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 + + @dataclass + class PrimeParams(BackendParams): + """Parameters for manifold prime. + + Attributes: + 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, default 9). + low_flow_volume: Low flow path volume in uL (5000-999000, default 5000). + Set to 0 to disable. + submerge_duration: Submerge duration in seconds (0 to disable, 60-86340). + Must be a multiple of 60. + """ + + volume: float = 10000.0 + buffer: Buffer = "A" + flow_rate: int = 9 + low_flow_volume: float = 5000.0 + submerge_duration: float = 0.0 + + @dataclass + class AspirateParams(BackendParams): + """Parameters for manifold aspirate. + + Attributes: + 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. + """ + + 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 + + @dataclass + class DispenseParams(BackendParams): + """Parameters for manifold dispense. + + Attributes: + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Dispense flow rate (1-11, default 7). + 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, default 9). + vacuum_delay_volume: Delay start of vacuum until volume dispensed in uL/well. + """ + + 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 + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver @staticmethod def _validate_manifold_xy(x: int, y: int, label: str) -> None: @@ -90,62 +297,10 @@ def _validate_manifold_xy(x: int, y: int, label: str) -> None: if not -40 <= y <= 40: raise ValueError(f"{label} Y offset must be -40..40, got {y}") - @staticmethod - def _validate_aspirate_mode_params( - vacuum_filtration: bool, - travel_rate: TravelRate, - delay_ms: int, - vacuum_time_sec: int, - ) -> tuple[int, int]: - """Validate aspirate mode-specific params and return (time_value, rate_byte).""" - 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}") - return (delay_ms, travel_rate_to_byte(travel_rate)) - - if not 5 <= vacuum_time_sec <= 999: - raise ValueError(f"Vacuum filtration time must be 5-999 seconds, got {vacuum_time_sec}") - return (vacuum_time_sec, travel_rate_to_byte("3")) - - @classmethod - def _validate_aspirate_offsets( - cls, - offset_x: int, - offset_y: int, - offset_z: int, - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - secondary_z: int, - ) -> None: - """Validate aspirate XYZ offset ranges (primary and secondary).""" - cls._validate_manifold_xy(offset_x, offset_y, "Aspirate") - if not 1 <= offset_z <= 210: - raise ValueError(f"Aspirate Z offset must be 1-210, got {offset_z}") - if secondary_aspirate: - cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") - if not 1 <= secondary_z <= 210: - raise ValueError(f"Secondary Z offset must be 1-210, got {secondary_z}") - def _validate_aspirate_params( self, plate: Plate, - vacuum_filtration: bool, - travel_rate: TravelRate, - delay_ms: int, - vacuum_time_sec: int, - offset_x: int, - offset_y: int, - offset_z: int | None, - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - secondary_z: int | None, + p: "EL406PlateWasher96Backend.AspirateParams", ) -> tuple[int, int, int, int]: """Validate aspirate parameters and resolve plate-type defaults. @@ -153,94 +308,88 @@ def _validate_aspirate_params( (offset_z, secondary_z, time_value, rate_byte) """ pt_defaults = get_plate_wash_defaults(plate) - if offset_z is None: - offset_z = pt_defaults["aspirate_z"] - if secondary_z is None: - secondary_z = pt_defaults["aspirate_z"] + offset_z = p.offset_z if p.offset_z is not None else pt_defaults["aspirate_z"] + secondary_z = p.secondary_z if p.secondary_z is not None else pt_defaults["aspirate_z"] + + # validate aspiration mode + delay_ms = round(p.delay * 1000) + vacuum_time_sec = round(p.vacuum_time) + if not p.vacuum_filtration: + if p.travel_rate not in TRAVEL_RATE_TO_BYTE: + raise ValueError( + f"Invalid travel rate '{p.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(p.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") - time_value, rate_byte = self._validate_aspirate_mode_params( - vacuum_filtration, - travel_rate, - delay_ms, - vacuum_time_sec, - ) - self._validate_aspirate_offsets( - offset_x, - offset_y, - offset_z, - secondary_aspirate, - secondary_x, - secondary_y, - secondary_z, - ) - return (offset_z, secondary_z, time_value, rate_byte) + # validate offsets + self._validate_manifold_xy(p.offset_x, p.offset_y, "Aspirate") + if not 1 <= offset_z <= 210: + raise ValueError(f"Aspirate Z offset must be 1-210, got {offset_z}") + if p.secondary_aspirate: + self._validate_manifold_xy(p.secondary_x, p.secondary_y, "Secondary") + if not 1 <= secondary_z <= 210: + raise ValueError(f"Secondary Z offset must be 1-210, got {secondary_z}") - @staticmethod - def _validate_dispense_extras( - pre_dispense_volume: float, - pre_dispense_flow_rate: int, - vacuum_delay_volume: float, - ) -> None: - """Validate pre-dispense and vacuum-delay parameters for manifold dispense.""" - 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, 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: int | None, - pre_dispense_volume: float, - pre_dispense_flow_rate: int, - vacuum_delay_volume: float, + p: "EL406PlateWasher96Backend.DispenseParams", ) -> int: """Validate dispense parameters and resolve plate-type defaults. Returns: Resolved offset_z. """ + offset_z = p.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: + validate_buffer(p.buffer) + if not 1 <= p.flow_rate <= 11: + raise ValueError(f"Manifold dispense flow rate must be 1-11, got {p.flow_rate}") + if p.flow_rate <= 2 and p.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}" + f"got flow_rate={p.flow_rate} with vacuum_delay_volume={p.vacuum_delay_volume}" ) - self._validate_manifold_xy(offset_x, offset_y, "Manifold dispense") + self._validate_manifold_xy(p.offset_x, p.offset_y, "Manifold dispense") if not 1 <= offset_z <= 210: raise ValueError(f"Manifold dispense Z offset must be 1-210, got {offset_z}") - self._validate_dispense_extras(pre_dispense_volume, pre_dispense_flow_rate, vacuum_delay_volume) + + # validate pre-dispense and vacuum delay + if p.pre_dispense_volume != 0 and not 25 <= p.pre_dispense_volume <= 3000: + raise ValueError( + f"Manifold pre-dispense volume must be 0 (disabled) or 25-3000 uL, " + f"got {p.pre_dispense_volume}" + ) + if not 3 <= p.pre_dispense_flow_rate <= 11: + raise ValueError(f"Manifold pre-dispense flow rate must be 3-11, got {p.pre_dispense_flow_rate}") + if not 0 <= p.vacuum_delay_volume <= 3000: + raise ValueError(f"Manifold vacuum delay volume must be 0-3000 uL, got {p.vacuum_delay_volume}") return offset_z def _resolve_wash_defaults( self, plate: Plate, - dispense_volume: float | None, - dispense_z: int | None, - aspirate_z: int | None, - secondary_z: int | None, - final_secondary_z: int | None, + 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) @@ -256,148 +405,31 @@ def _resolve_wash_defaults( final_secondary_z = pt_defaults["aspirate_z"] return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) - @classmethod - def _validate_wash_core_params( - cls, - cycles: int, - buffer: Buffer, - dispense_volume: float, - dispense_flow_rate: int, - dispense_x: int, - dispense_y: int, - aspirate_travel_rate: int, - aspirate_x: int, - aspirate_y: int, - pre_dispense_flow_rate: int, - aspirate_delay_ms: int, - wash_format: Literal["Plate", "Sector"], - sector_mask: int, - ) -> None: - """Validate core wash dispense/aspirate parameters.""" - 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) - cls._validate_manifold_xy(dispense_x, dispense_y, "Wash dispense") - validate_travel_rate(aspirate_travel_rate) - cls._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) - - @classmethod - def _validate_wash_final_and_extras( - cls, - final_aspirate_x: int, - final_aspirate_y: int, - final_aspirate_delay_ms: int, - pre_dispense_volume: float, - vacuum_delay_volume: float, - soak_duration: int, - shake_duration: int, - shake_intensity: Intensity, - ) -> None: - """Validate final-aspirate, pre-dispense, soak/shake parameters.""" - cls._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) - - @classmethod - def _validate_wash_secondary_aspirates( - cls, - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - final_secondary_aspirate: bool, - final_secondary_x: int, - final_secondary_y: int, - ) -> None: - """Validate secondary and final-secondary aspirate offsets.""" - if secondary_aspirate: - cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") - if final_secondary_aspirate: - cls._validate_manifold_xy(final_secondary_x, final_secondary_y, "Final secondary") - - @staticmethod - def _validate_wash_optional_features( - 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, - ) -> None: - """Validate 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) - def _validate_wash_params( self, plate: Plate, cycles: int, - buffer: Buffer, - dispense_volume: float | None, - dispense_flow_rate: int, - dispense_x: int, - dispense_y: int, - dispense_z: int | None, - aspirate_travel_rate: int, - aspirate_z: int | None, - aspirate_x: int, - aspirate_y: int, - pre_dispense_flow_rate: int, - aspirate_delay_ms: int, - final_aspirate_x: int, - final_aspirate_y: int, - final_aspirate_delay_ms: int, - pre_dispense_volume: float, - vacuum_delay_volume: float, - soak_duration: int, - shake_duration: int, - shake_intensity: Intensity, - secondary_aspirate: bool, - secondary_z: int | None, - secondary_x: int, - secondary_y: int, - final_secondary_aspirate: bool, - final_secondary_z: int | None, - 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"], - sector_mask: int, - ) -> tuple[float, int, int, int, int]: + dispense_volume: Optional[float], + p: "EL406PlateWasher96Backend.WashParams", + ) -> 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) + (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z, + aspirate_delay_ms, final_aspirate_delay_ms, sector_mask) """ + aspirate_delay_ms = round(p.aspirate_delay * 1000) + final_aspirate_delay_ms = round(p.final_aspirate_delay * 1000) + + if p.sectors is not None: + sector_mask = 0 + for q in p.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, dispense_z, @@ -405,258 +437,146 @@ def _validate_wash_params( secondary_z, final_secondary_z, ) = self._resolve_wash_defaults( - plate, - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) - self._validate_wash_core_params( - cycles, - buffer, - dispense_volume, - dispense_flow_rate, - dispense_x, - dispense_y, - aspirate_travel_rate, - aspirate_x, - aspirate_y, - pre_dispense_flow_rate, - aspirate_delay_ms, - wash_format, - sector_mask, - ) - self._validate_wash_final_and_extras( - final_aspirate_x, - final_aspirate_y, - final_aspirate_delay_ms, - pre_dispense_volume, - vacuum_delay_volume, - soak_duration, - shake_duration, - shake_intensity, - ) - self._validate_wash_secondary_aspirates( - secondary_aspirate, - secondary_x, - secondary_y, - final_secondary_aspirate, - final_secondary_x, - final_secondary_y, + plate, dispense_volume, p.dispense_z, p.aspirate_z, p.secondary_z, p.final_secondary_z, ) - self._validate_wash_optional_features( - bottom_wash, - bottom_wash_volume, - bottom_wash_flow_rate, - pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate, - ) - return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) - async def manifold_aspirate( + # core dispense/aspirate params + validate_cycles(cycles) + if dispense_volume <= 0: + raise ValueError(f"dispense_volume must be positive, got {dispense_volume}") + validate_buffer(p.buffer) + validate_flow_rate(p.dispense_flow_rate) + self._validate_manifold_xy(p.dispense_x, p.dispense_y, "Wash dispense") + validate_travel_rate(p.aspirate_travel_rate) + self._validate_manifold_xy(p.aspirate_x, p.aspirate_y, "Wash aspirate") + if p.wash_format not in ("Plate", "Sector"): + raise ValueError(f"wash_format must be 'Plate' or 'Sector', got '{p.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(p.pre_dispense_flow_rate) + validate_delay_ms(aspirate_delay_ms) + + # final aspirate, pre-dispense, soak/shake + self._validate_manifold_xy(p.final_aspirate_x, p.final_aspirate_y, "Final aspirate") + validate_delay_ms(final_aspirate_delay_ms) + if p.pre_dispense_volume != 0 and not 25 <= p.pre_dispense_volume <= 3000: + raise ValueError( + f"Wash pre-dispense volume must be 0 (disabled) or 25-3000 uL, got {p.pre_dispense_volume}" + ) + if not 0 <= p.vacuum_delay_volume <= 3000: + raise ValueError(f"Wash vacuum delay volume must be 0-3000 uL, got {p.vacuum_delay_volume}") + if not 0 <= p.soak_duration <= 3599: + raise ValueError(f"Wash soak duration must be 0-3599 seconds, got {p.soak_duration}") + if not 0 <= p.shake_duration <= 3599: + raise ValueError(f"Wash shake duration must be 0-3599 seconds, got {p.shake_duration}") + validate_intensity(p.shake_intensity) + + # secondary aspirates + if p.secondary_aspirate: + self._validate_manifold_xy(p.secondary_x, p.secondary_y, "Secondary") + if p.final_secondary_aspirate: + self._validate_manifold_xy(p.final_secondary_x, p.final_secondary_y, "Final secondary") + + # bottom wash and mid-cycle pre-dispense + if p.bottom_wash: + if not 25 <= p.bottom_wash_volume <= 3000: + raise ValueError(f"Bottom wash volume must be 25-3000 uL, got {p.bottom_wash_volume}") + validate_flow_rate(p.bottom_wash_flow_rate) + if p.pre_dispense_between_cycles_volume != 0: + if not 25 <= p.pre_dispense_between_cycles_volume <= 3000: + raise ValueError( + f"Pre-dispense between cycles volume must be 0 (disabled) or " + f"25-3000 uL, got {p.pre_dispense_between_cycles_volume}" + ) + validate_flow_rate(p.pre_dispense_between_cycles_flow_rate) + + return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z, + aspirate_delay_ms, final_aspirate_delay_ms, sector_mask) + + async def aspirate( self, plate: Plate, - 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: int | None = None, - secondary_aspirate: bool = False, - secondary_x: int = 0, - secondary_y: int = 0, - secondary_z: int | None = None, + backend_params: Optional[BackendParams] = None, ) -> None: """Aspirate liquid from all wells via the wash manifold. - Two modes based on vacuum_filtration: + Two modes based on ``vacuum_filtration`` in :class:`AspirateParams`: + - 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: plate: PLR Plate resource. - vacuum_filtration: Enable vacuum filtration mode. - travel_rate: Head travel rate. Normal: "1"-"5". - Cell wash: "1 CW", "2 CW", "3 CW", "4 CW", "6 CW". - Ignored when vacuum_filtration=True. - delay: Post-aspirate delay in seconds (0-5). Only used when - vacuum_filtration=False. Wire resolution: 1 ms. - vacuum_time: Vacuum filtration time in seconds (5-999). Only used when - vacuum_filtration=True. - 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). Default None (plate-type-aware: - 29 for 96-well, 22 for 384-well, etc.). - secondary_aspirate: Enable secondary aspirate (perform a second aspirate - at a different position). Not available for 1536-well plates. - secondary_x: Secondary aspirate X offset (-60 to +60). - secondary_y: Secondary aspirate Y offset (-40 to +40). - secondary_z: Secondary aspirate Z offset (1-210). Default None - (plate-type-aware, same as offset_z default). - - Raises: - ValueError: If parameters are invalid. + backend_params: :class:`AspirateParams` with vacuum_filtration, travel_rate, + delay, vacuum_time, offset_x/y/z, secondary aspirate settings. """ - # Convert PLR units (seconds) to wire units: seconds → milliseconds, seconds → integer seconds - delay_ms = round(delay * 1000) - vacuum_time_sec = round(vacuum_time) + if not isinstance(backend_params, self.AspirateParams): + backend_params = self.AspirateParams() + p = backend_params - offset_z, secondary_z, time_value, rate_byte = self._validate_aspirate_params( - plate=plate, - vacuum_filtration=vacuum_filtration, - travel_rate=travel_rate, - delay_ms=delay_ms, - vacuum_time_sec=vacuum_time_sec, - 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, - ) + offset_z, secondary_z, time_value, rate_byte = self._validate_aspirate_params(plate, p) - logger.info( - "Aspirating: vacuum=%s, travel_rate=%s, delay=%.3f s", - vacuum_filtration, - travel_rate, - delay, - ) + logger.info("Aspirating: vacuum=%s, travel_rate=%s, delay=%.3f s", + p.vacuum_filtration, p.travel_rate, p.delay) data = self._build_aspirate_command( plate=plate, - vacuum_filtration=vacuum_filtration, + vacuum_filtration=p.vacuum_filtration, time_value=time_value, travel_rate_byte=rate_byte, - offset_x=offset_x, - offset_y=offset_y, + offset_x=p.offset_x, + offset_y=p.offset_y, offset_z=offset_z, - secondary_mode=1 if secondary_aspirate else 0, - secondary_x=secondary_x, - secondary_y=secondary_y, + secondary_mode=1 if p.secondary_aspirate else 0, + secondary_x=p.secondary_x, + secondary_y=p.secondary_y, secondary_z=secondary_z, ) framed_command = build_framed_message(command=0xA5, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) + async with self._driver.batch(): + await self._driver._send_step_command(framed_command) - async def manifold_dispense( + async def dispense( self, plate: Plate, volume: float, - buffer: Buffer = "A", - flow_rate: int = 7, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int | None = None, - pre_dispense_volume: float = 0.0, - pre_dispense_flow_rate: int = 9, - vacuum_delay_volume: float = 0.0, + backend_params: Optional[BackendParams] = None, ) -> None: """Dispense liquid to all wells via the wash manifold. Args: plate: PLR Plate resource. - volume: Volume to dispense in uL/well. Range: 25-3000 uL (manifold-dependent: - 96-tube manifolds require ≥50, 192/128-tube manifolds allow ≥25). - buffer: Buffer valve selection (A, B, C, D). - flow_rate: Dispense flow rate (1-11, default 7). - Rates 1-2 are for cell wash mode only (96-tube dual-action manifold) - and require vacuum_delay_volume > 0. - Standard range is 3-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). Default None (plate-type-aware: - 121 for 96-well, 120 for 384-well, etc.). - pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, 25-3000 when enabled). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11, default 9). - vacuum_delay_volume: Delay start of vacuum until volume dispensed in uL/well - (0 to disable, 0-3000 when enabled). Required for cell wash flow rates 1-2. - - Raises: - ValueError: If parameters are invalid. + volume: Volume to dispense in uL/well (25-3000). + backend_params: :class:`DispenseParams` with buffer, flow_rate, offsets, + pre_dispense_volume, pre_dispense_flow_rate, vacuum_delay_volume. """ - offset_z = self._validate_dispense_params( - plate=plate, - volume=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, - ) + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + p = backend_params - logger.info( - "Dispensing %.1f uL from buffer %s, flow rate %d", - volume, - buffer, - flow_rate, - ) + offset_z = self._validate_dispense_params(plate, volume, p) + + logger.info("Dispensing %.1f uL from buffer %s, flow rate %d", + volume, p.buffer, p.flow_rate) data = self._build_dispense_command( - plate=plate, - volume=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, + plate=plate, volume=volume, buffer=p.buffer, flow_rate=p.flow_rate, + offset_x=p.offset_x, offset_y=p.offset_y, offset_z=offset_z, + pre_dispense_volume=p.pre_dispense_volume, + pre_dispense_flow_rate=p.pre_dispense_flow_rate, + vacuum_delay_volume=p.vacuum_delay_volume, ) framed_command = build_framed_message(command=0xA6, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) + async with self._driver.batch(): + await self._driver._send_step_command(framed_command) - async def manifold_wash( + async def wash( self, plate: Plate, cycles: int = 3, - buffer: Buffer = "A", - dispense_volume: float | None = None, - dispense_flow_rate: int = 7, - dispense_x: int = 0, - dispense_y: int = 0, - dispense_z: int | None = None, - aspirate_travel_rate: int = 3, - aspirate_z: int | None = 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: int | None = 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: int | None = None, - secondary_x: int = 0, - secondary_y: int = 0, - final_secondary_aspirate: bool = False, - final_secondary_z: int | None = 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: list[int] | None = None, - move_home_first: bool = False, + dispense_volume: Optional[float] = None, + backend_params: Optional[BackendParams] = None, ) -> None: """Perform manifold wash cycles. @@ -674,228 +594,62 @@ async def manifold_wash( plate: PLR Plate resource. cycles: Number of wash cycles (1-250). Default 3. Encoded at header byte [6]. - buffer: Buffer valve selection (A, B, C, D). Default A. dispense_volume: Volume to dispense per cycle in uL. Default None (plate-type-aware: 300 for 96-well, 100 for others). - dispense_flow_rate: Flow rate for dispensing (1-9). Default 7. - dispense_x: Dispense X offset in steps (-60 to +60). Default 0. - dispense_y: Dispense Y offset in steps (-40 to +40). Default 0. - dispense_z: Z offset for dispense in 0.1mm units (1-210). Default None - (plate-type-aware: 121 for 96-well, 120 for 384-well, etc.). - aspirate_travel_rate: Travel rate for aspiration (1-9). Default 3. - aspirate_z: Z offset for aspirate in 0.1mm units (1-210). Default None - (plate-type-aware: 29 for 96-well, 22 for 384-well, etc.). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Default 9. - Controls how fast the pre-dispense is delivered. - aspirate_delay: Post-aspirate delay in seconds (0-65.535). Default 0. - Wire resolution: 1 ms. - aspirate_x: Aspirate X offset in steps (-60 to +60). Default 0. - aspirate_y: Aspirate Y offset in steps (-40 to +40). Default 0. - final_aspirate: Enable final aspirate after last cycle. Default True. - Encoded in header config flags byte [2]. - final_aspirate_z: Z offset for final aspirate (1-210). Default None - (inherits from aspirate_z). Independent from primary aspirate Z. - final_aspirate_x: X offset for final aspirate (-60 to +60). Default 0. - final_aspirate_y: Y offset for final aspirate (-40 to +40). Default 0. - final_aspirate_delay: Post-aspirate delay for final aspirate in - seconds (0-65.535). Default 0. Wire resolution: 1 ms. - pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, - 25-3000 when enabled). Default 0.0. - vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, - 0-3000 when enabled). Cell wash operations only. Default 0.0. - soak_duration: Soak duration in seconds (0 to disable, 0-3599). Default 0. - shake_duration: Shake duration in seconds (0 to disable, 0-3599). Default 0. - shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). - Default "Medium". - secondary_aspirate: Enable secondary aspirate for primary (between-cycle) - aspirate. Default False. - secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). - Default None (plate-type-aware, same as aspirate_z default). - secondary_x: Secondary aspirate X offset (-60 to +60). Default 0. - secondary_y: Secondary aspirate Y offset (-40 to +40). Default 0. - final_secondary_aspirate: Enable secondary aspirate for final aspirate. - Default False. - final_secondary_z: Z offset for final secondary aspirate (1-210). - Default None (plate-type-aware, same as aspirate_z default). - final_secondary_x: X offset for final secondary aspirate (-60 to +60). - Default 0. - final_secondary_y: Y offset for final secondary aspirate (-40 to +40). - Default 0. - bottom_wash: Enable bottom wash. Default False. Encoded in header[1]. - bottom_wash_volume: Bottom wash volume in uL (25-3000). Default 0.0. - bottom_wash_flow_rate: Bottom wash flow rate (3-11). Default 5. - pre_dispense_between_cycles_volume: Pre-dispense volume between wash - cycles in uL (0 to disable, 25-3000 when enabled). Default 0.0. - pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between - cycles (3-11). Default 9. - wash_format: Wash format ("Plate" or "Sector"). Default "Plate". - 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). Default None (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. - Default False. Same as in standalone shake interface. - Encoded at wire [87] (shake/soak section byte 0). + backend_params: :class:`WashParams` with buffer, flow rates, offsets, + aspirate settings, soak/shake, secondary aspirate, bottom wash, + wash format, and other manifold-specific parameters. See + :class:`WashParams` for full documentation of all fields. Raises: ValueError: If parameters are invalid. """ - # Convert PLR units (seconds) to wire units (ms) - aspirate_delay_ms = round(aspirate_delay * 1000) - final_aspirate_delay_ms = round(final_aspirate_delay * 1000) - - # Convert sectors list to bitmask - 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 + if not isinstance(backend_params, self.WashParams): + backend_params = self.WashParams() + p = backend_params + # Validate — returns resolved defaults and derived wire values ( - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) = self._validate_wash_params( - plate=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=dispense_z, - aspirate_travel_rate=aspirate_travel_rate, - aspirate_z=aspirate_z, - aspirate_x=aspirate_x, - aspirate_y=aspirate_y, - pre_dispense_flow_rate=pre_dispense_flow_rate, - aspirate_delay_ms=aspirate_delay_ms, - 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=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, - sector_mask=sector_mask, - ) - - logger.info( - "Manifold wash: %d cycles, %.1f uL, buffer %s, flow %d, " - "disp_xy=(%d,%d), z_disp=%d, z_asp=%d, pre_disp_flow=%d, " - "asp_delay=%.3f s, asp_xy=(%d,%d), final_asp=%s, " - "pre_disp=%.1f, vac_delay=%.1f, soak=%d, shake=%d/%s, " - "sec_asp=%s, sec_z=%d, sec_xy=(%d,%d), " - "btm_wash=%s/%.1f/%d, midcyc=%.1f/%d", - cycles, - dispense_volume, - buffer, - dispense_flow_rate, - dispense_x, - dispense_y, - dispense_z, - aspirate_z, - pre_dispense_flow_rate, - aspirate_delay, - aspirate_x, - aspirate_y, - final_aspirate, - pre_dispense_volume, - vacuum_delay_volume, - soak_duration, - shake_duration, - shake_intensity, - secondary_aspirate, - secondary_z, - secondary_x, - secondary_y, - bottom_wash, - bottom_wash_volume, - bottom_wash_flow_rate, - pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate, - ) + dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z, + aspirate_delay_ms, final_aspirate_delay_ms, sector_mask, + ) = self._validate_wash_params(plate, cycles, dispense_volume, p) data = self._build_wash_composite_command( - plate=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=dispense_z, - aspirate_travel_rate=aspirate_travel_rate, - aspirate_z=aspirate_z, - pre_dispense_flow_rate=pre_dispense_flow_rate, + plate=plate, cycles=cycles, buffer=p.buffer, + dispense_volume=dispense_volume, dispense_flow_rate=p.dispense_flow_rate, + dispense_x=p.dispense_x, dispense_y=p.dispense_y, dispense_z=dispense_z, + aspirate_travel_rate=p.aspirate_travel_rate, aspirate_z=aspirate_z, + pre_dispense_flow_rate=p.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, + aspirate_x=p.aspirate_x, aspirate_y=p.aspirate_y, + final_aspirate=p.final_aspirate, final_aspirate_z=p.final_aspirate_z, + final_aspirate_x=p.final_aspirate_x, final_aspirate_y=p.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=secondary_z, - secondary_x=secondary_x, - secondary_y=secondary_y, - final_secondary_aspirate=final_secondary_aspirate, + pre_dispense_volume=p.pre_dispense_volume, vacuum_delay_volume=p.vacuum_delay_volume, + soak_duration=p.soak_duration, shake_duration=p.shake_duration, + shake_intensity=p.shake_intensity, + secondary_aspirate=p.secondary_aspirate, secondary_z=secondary_z, + secondary_x=p.secondary_x, secondary_y=p.secondary_y, + final_secondary_aspirate=p.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, - sector_mask=sector_mask, - move_home_first=move_home_first, + final_secondary_x=p.final_secondary_x, final_secondary_y=p.final_secondary_y, + bottom_wash=p.bottom_wash, bottom_wash_volume=p.bottom_wash_volume, + bottom_wash_flow_rate=p.bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=p.pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=p.pre_dispense_between_cycles_flow_rate, + wash_format=p.wash_format, sector_mask=sector_mask, + move_home_first=p.move_home_first, ) framed_command = build_framed_message(command=0xA4, data=data) - # Dynamic timeout: base per cycle + shake + soak + buffer - # Each cycle takes ~10-30s depending on volume/flow/plate type. - # Use 60s per cycle as generous safety margin to avoid false timeouts. - wash_timeout = (cycles * 60) + shake_duration + soak_duration + 120 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=wash_timeout) - - async def manifold_prime( + wash_timeout = (cycles * 60) + p.shake_duration + p.soak_duration + 120 + async with self._driver.batch(): + await self._driver._send_step_command(framed_command, timeout=wash_timeout) + + async def prime( self, plate: Plate, - volume: float, - buffer: Buffer = "A", - flow_rate: int = 9, - low_flow_volume: float = 5000.0, - submerge_duration: float = 0.0, + backend_params: Optional[BackendParams] = None, ) -> None: """Prime the manifold fluid lines. @@ -905,75 +659,53 @@ async def manifold_prime( Args: plate: PLR Plate resource. - volume: Prime volume in uL. Range: 5000-999000 uL. - Wire resolution: 1000 uL (1 mL). - buffer: Buffer valve selection (A, B, C, D). - flow_rate: Flow rate (3-11, default 9). - low_flow_volume: Low flow path volume in uL (5000-999000, default 5000). - Set to 0 to disable. Wire resolution: 1000 uL (1 mL). - submerge_duration: Submerge duration in seconds (0 to disable, 60-86340 when - enabled). Wire resolution: 60 s (1 minute). + backend_params: :class:`PrimeParams` with volume, buffer, flow_rate, + low_flow_volume, submerge_duration. Raises: ValueError: If parameters are invalid. """ + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + p = backend_params + # 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: + if not 5000 <= p.volume <= 999000: + raise ValueError(f"Washer prime volume must be 5000-999000 uL, got {p.volume}") + validate_buffer(p.buffer) + if not 3 <= p.flow_rate <= 11: + raise ValueError(f"Washer prime flow rate must be 3-11, got {p.flow_rate}") + if p.low_flow_volume != 0 and not 5000 <= p.low_flow_volume <= 999000: raise ValueError( - f"Low flow path volume must be 0 (disabled) or 5000-999000 uL, got {low_flow_volume}" + f"Low flow path volume must be 0 (disabled) or 5000-999000 uL, got {p.low_flow_volume}" ) - if submerge_duration != 0 and not 60 <= submerge_duration <= 86340: + if p.submerge_duration != 0 and not 60 <= p.submerge_duration <= 86340: raise ValueError( - f"Submerge duration must be 0 (disabled) or 60-86340 seconds, got {submerge_duration}" + f"Submerge duration must be 0 (disabled) or 60-86340 seconds, got {p.submerge_duration}" ) - if submerge_duration % 60 != 0: + if p.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}" + f"got {p.submerge_duration}" ) - # Convert to wire units: uL → mL, seconds → minutes - 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 - - logger.info( - "Manifold prime: %.1f uL from buffer %s, flow rate %d, low_flow=%s/%.0f uL, " - "submerge=%s/%.0f s", - volume, - buffer, - flow_rate, - "enabled" if low_flow_enabled else "disabled", - low_flow_volume, - "enabled" if submerge_enabled else "disabled", - submerge_duration, - ) + volume_ml = round(p.volume / 1000) + low_flow_volume_ml = round(p.low_flow_volume / 1000) + submerge_duration_min = round(p.submerge_duration / 60) + low_flow_enabled = p.low_flow_volume > 0 + submerge_enabled = p.submerge_duration > 0 - data = self._build_manifold_prime_command( - plate=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, + data = self._build_prime_command( + plate=plate, buffer=p.buffer, volume_ml=volume_ml, flow_rate=p.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) - # Timeout: base time for priming + submerge duration + buffer - prime_timeout = self.timeout + submerge_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=prime_timeout) + prime_timeout = self._driver.timeout + p.submerge_duration + 30 + async with self._driver.batch(): + await self._driver._send_step_command(framed_command, timeout=prime_timeout) - async def manifold_auto_clean( + async def auto_clean( self, plate: Plate, buffer: Buffer = "A", @@ -1011,8 +743,8 @@ async def manifold_auto_clean( ) framed_command = build_framed_message(command=0xA8, data=data) auto_clean_timeout = max(120.0, duration + 30.0) - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=auto_clean_timeout) + async with self._driver.batch(): + await self._driver._send_step_command(framed_command, timeout=auto_clean_timeout) # ========================================================================= # COMMAND BUILDERS @@ -1023,19 +755,19 @@ def _build_wash_composite_command( plate: Plate, cycles: int = 3, buffer: Buffer = "A", - dispense_volume: float | None = None, + dispense_volume: Optional[float] = None, dispense_flow_rate: int = 7, dispense_x: int = 0, dispense_y: int = 0, - dispense_z: int | None = None, + dispense_z: Optional[int] = None, aspirate_travel_rate: int = 3, - aspirate_z: int | None = None, + 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: int | None = None, + final_aspirate_z: Optional[int] = None, final_aspirate_x: int = 0, final_aspirate_y: int = 0, final_aspirate_delay_ms: int = 0, @@ -1045,11 +777,11 @@ def _build_wash_composite_command( shake_duration: int = 0, shake_intensity: Intensity = "Medium", secondary_aspirate: bool = False, - secondary_z: int | None = None, + secondary_z: Optional[int] = None, secondary_x: int = 0, secondary_y: int = 0, final_secondary_aspirate: bool = False, - final_secondary_z: int | None = None, + final_secondary_z: Optional[int] = None, final_secondary_x: int = 0, final_secondary_y: int = 0, bottom_wash: bool = False, @@ -1331,7 +1063,7 @@ def _build_dispense_command( .finish() ) # fmt: skip - def _build_manifold_prime_command( + def _build_prime_command( self, plate: Plate, buffer: Buffer, 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/plate_washing/biotek/el406/steps/_shake.py b/pylabrobot/agilent/biotek/el406/shaking_backend.py similarity index 62% rename from pylabrobot/plate_washing/biotek/el406/steps/_shake.py rename to pylabrobot/agilent/biotek/el406/shaking_backend.py index d2323447614..6d4405fa4bf 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps/_shake.py +++ b/pylabrobot/agilent/biotek/el406/shaking_backend.py @@ -1,19 +1,23 @@ -"""EL406 shake/soak step methods. +"""EL406 shake/soak backend. Provides the shake operation and its command builder. +This is a direct port of the legacy EL406ShakeStepsMixin. """ from __future__ import annotations import logging -from typing import Literal +from dataclasses import dataclass +from typing import Literal, Optional +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.shaking.backend import ShakerBackend from pylabrobot.io.binary import Writer from pylabrobot.resources import Plate -from ..helpers import plate_to_wire_byte -from ..protocol import build_framed_message -from ._base import EL406StepsBaseMixin +from .driver import EL406Driver +from .helpers import plate_to_wire_byte +from .protocol import build_framed_message INTENSITY_TO_BYTE: dict[str, int] = { "Variable": 0x01, @@ -35,54 +39,100 @@ def validate_intensity(intensity: Intensity) -> None: ) -class EL406ShakeStepsMixin(EL406StepsBaseMixin): - """Mixin for shake/soak step operations.""" +class EL406ShakingBackend(ShakerBackend): + """Shaking backend for the BioTek EL406. + + The EL406 shake is a single fire-and-forget command with duration baked in. + It does not support continuous start/stop shaking or plate locking. + """ + + @dataclass + class ShakeParams(BackendParams): + """EL406-specific shake parameters. + + Attributes: + 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 (default True). + """ + + intensity: Intensity = "Medium" + soak_duration: int = 0 + move_home_first: bool = True + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver + + # -- ShakerBackend interface -- + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("EL406 does not support plate locking.") + + async def unlock_plate(self): + raise NotImplementedError("EL406 does not support plate locking.") + + # -- Shake -- 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, - plate: Plate, - duration: int = 0, - intensity: Intensity = "Medium", - soak_duration: int = 0, - move_home_first: bool = True, + speed: float, + duration: float, + backend_params: Optional[BackendParams] = None, ) -> None: """Shake the plate with optional soak period. + The ``speed`` parameter is accepted to satisfy the generic shaker interface + but is ignored — the EL406 uses discrete intensity levels, not RPM. Use + :class:`ShakeParams` for EL406-specific control. + 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 the driver (set by assigning to the device's plate_holder). + Args: - plate: PLR Plate resource. + speed: Ignored (EL406 uses intensity levels, not RPM). 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 (default True). + backend_params: EL406-specific parameters (:class:`ShakeParams`). Raises: ValueError: If parameters are invalid. """ - if duration < 0 or duration > self.MAX_SHAKE_DURATION: - raise ValueError(f"Invalid duration {duration}. Must be 0-{self.MAX_SHAKE_DURATION}.") + if not isinstance(backend_params, self.ShakeParams): + backend_params = self.ShakeParams() + + dur = int(duration) + plate = self._driver.plate + intensity = backend_params.intensity + soak_duration = backend_params.soak_duration + move_home_first = backend_params.move_home_first + + 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 duration == 0 and soak_duration == 0: + 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 = duration > 0 + shake_enabled = dur > 0 logger.info( "Shake: %ds, %s intensity, move_home=%s, soak=%ds", - duration, + dur, intensity, move_home_first, soak_duration, @@ -90,16 +140,16 @@ async def shake( data = self._build_shake_command( plate=plate, - shake_duration=duration, + 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 = duration + soak_duration + self.timeout - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=total_timeout) + total_timeout = dur + soak_duration + self._driver.timeout + async with self._driver.batch(): + await self._driver._send_step_command(framed_command, timeout=total_timeout) # ========================================================================= # COMMAND BUILDERS diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py b/pylabrobot/agilent/biotek/el406/syringe_dispensing_backend8.py similarity index 62% rename from pylabrobot/plate_washing/biotek/el406/steps/_syringe.py rename to pylabrobot/agilent/biotek/el406/syringe_dispensing_backend8.py index d9d4ca184a8..9eabd32c30e 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py +++ b/pylabrobot/agilent/biotek/el406/syringe_dispensing_backend8.py @@ -7,17 +7,20 @@ from __future__ import annotations import logging -from typing import Literal +from dataclasses import dataclass +from typing import Dict, Literal, Optional +from pylabrobot.capabilities.bulk_dispensers.syringe.backend8 import SyringeDispensingBackend8 +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.io.binary import Writer from pylabrobot.resources import Plate -from ..helpers import ( +from .driver import EL406Driver +from .helpers import ( plate_to_wire_byte, plate_well_count, ) -from ..protocol import build_framed_message, columns_to_column_mask, encode_column_mask -from ._base import EL406StepsBaseMixin +from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask logger = logging.getLogger(__name__) @@ -45,48 +48,19 @@ def validate_syringe_flow_rate(flow_rate: int) -> None: raise ValueError(f"Syringe flow rate must be 1-5, got {flow_rate}") -def validate_syringe_volume(volume: float) -> None: - if not 80 <= volume <= 9999: - raise ValueError(f"Syringe volume must be 80-9999 uL, got {volume}") - - def validate_pump_delay(delay: int) -> None: if not 0 <= delay <= 5000: raise ValueError(f"Pump delay must be 0-5000 ms, got {delay}") -def validate_submerge_duration(duration: int) -> None: - if not 0 <= duration <= 1439: - raise ValueError(f"Submerge duration must be 0-1439 minutes, got {duration}") - - -class EL406SyringeStepsMixin(EL406StepsBaseMixin): - """Mixin for syringe pump step operations.""" +class EL406SyringeDispensingBackend8(SyringeDispensingBackend8): + """Syringe dispensing backend for the BioTek EL406.""" - async def syringe_dispense( - self, - plate: Plate, - volume: float, - syringe: Syringe = "A", - flow_rate: int = 2, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 336, - pump_delay: float = 0.0, - pre_dispense: bool = False, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - columns: list[int] | None = None, - ) -> None: - """Dispense liquid using the syringe pump. + @dataclass + class DispenseParams(BackendParams): + """Parameters for syringe dispense. - Args: - plate: PLR Plate resource. - volume: Dispense volume in microliters per well. - Volume range depends on plate type: - - 96-well: 10-3000 uL - - 384-well: 5-1500 uL - - 1536-well: 3-3000 uL + Attributes: 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, @@ -94,125 +68,173 @@ async def syringe_dispense( 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 (signed, 0.1mm units). - offset_y: Y offset (signed, 0.1mm units). - offset_z: Z offset (0.1mm units, default 336 for 96-well, 254 for 1536-well). + offset_x: X offset in mm (default 0). + offset_y: Y offset in mm (default 0). + offset_z: Z offset in mm (default 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 (default 2). - columns: List of 1-indexed column numbers to dispense to, or None for all columns. - For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. - - Raises: - ValueError: If parameters are invalid. """ - # Convert PLR units (seconds) to wire units (ms) - pump_delay_ms = round(pump_delay * 1000) + + 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 + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + + # 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._driver.batch(): + for vol, cols in groups: + await self._syringe_dispense(plate, volume=vol, columns=cols, params=backend_params) + + async def _syringe_dispense( + self, + plate: Plate, + volume: float, + columns: Optional[list[int]] = None, + params: Optional[DispenseParams] = None, + ) -> None: + """Send a single syringe dispense command to the firmware.""" + if params is None: + params = self.DispenseParams() + p = params + + pump_delay_ms = round(p.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_syringe(p.syringe) + validate_syringe_flow_rate(p.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, - ) + logger.info("Syringe dispense: %.1f uL from syringe %s, flow rate %d", + volume, p.syringe, p.flow_rate) + + # Convert mm → 0.1mm steps for wire protocol + offset_x_steps = round(p.offset_x * 10) + offset_y_steps = round(p.offset_y * 10) + offset_z_steps = round(p.offset_z * 10) data = self._build_syringe_dispense_command( - plate=plate, - volume=volume, - syringe=syringe, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - 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, + plate=plate, volume=volume, syringe=p.syringe, flow_rate=p.flow_rate, + offset_x=offset_x_steps, offset_y=offset_y_steps, offset_z=offset_z_steps, + pump_delay_ms=pump_delay_ms, pre_dispense=p.pre_dispense, + pre_dispense_volume=p.pre_dispense_volume, + num_pre_dispenses=p.num_pre_dispenses, column_mask=column_mask, ) framed_command = build_framed_message(command=0xA1, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) + async with self._driver.batch(): + await self._driver._send_step_command(framed_command) - async def syringe_prime( - self, - plate: Plate, - syringe: Literal["A", "B"] = "A", - volume: float = 5000.0, - 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 system. - - Fills the syringe tubing by drawing and expelling liquid. + @dataclass + class PrimeParams(BackendParams): + """Parameters for syringe prime. - Args: - plate: PLR Plate resource. + Attributes: syringe: Syringe selection — "A" or "B". - volume: Volume to prime in microliters (80-9999). 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 (default True). 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). + 0 to disable submerge time. Only encoded when submerge_tips=True. + Wire resolution: 60 s (1 minute). + """ + + 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 + + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> 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. + + Args: + plate: PLR Plate resource. + volume: Prime volume in uL per refill (80-9999). + backend_params: :class:`PrimeParams` with syringe, flow_rate, refills, + pump_delay, submerge_tips, and submerge_duration settings. Raises: ValueError: If parameters are invalid. """ - # Convert to wire units: seconds → milliseconds, seconds → minutes - pump_delay_ms = round(pump_delay * 1000) - if submerge_duration != 0 and submerge_duration % 60 != 0: + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + p = backend_params + + pump_delay_ms = round(p.pump_delay * 1000) + if p.submerge_duration != 0 and p.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}" + f"got {p.submerge_duration}" ) - submerge_duration_min = round(submerge_duration / 60) + submerge_duration_min = round(p.submerge_duration / 60) - validate_syringe(syringe) - validate_syringe_volume(volume) - validate_syringe_flow_rate(flow_rate) + validate_syringe(p.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(p.flow_rate) validate_pump_delay(pump_delay_ms) - validate_submerge_duration(submerge_duration_min) - if not 1 <= refills <= 255: - raise ValueError(f"refills must be 1-255, got {refills}") + # 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 <= p.refills <= 255: + raise ValueError(f"refills must be 1-255, got {p.refills}") logger.info( "Syringe prime: syringe %s, %.1f uL, flow rate %d, %d refills", - syringe, - volume, - flow_rate, - refills, + p.syringe, volume, p.flow_rate, p.refills, ) data = self._build_syringe_prime_command( - plate=plate, - volume=volume, - syringe=syringe, - flow_rate=flow_rate, - refills=refills, - pump_delay_ms=pump_delay_ms, - submerge_tips=submerge_tips, + plate=plate, volume=volume, syringe=p.syringe, + flow_rate=p.flow_rate, refills=p.refills, + pump_delay_ms=pump_delay_ms, submerge_tips=p.submerge_tips, submerge_duration_min=submerge_duration_min, ) framed_command = build_framed_message(command=0xA2, data=data) - # Timeout: base for priming + submerge duration + buffer - prime_timeout = self.timeout + submerge_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=prime_timeout) + prime_timeout = self._driver.timeout + p.submerge_duration + 30 + async with self._driver.batch(): + await self._driver._send_step_command(framed_command, timeout=prime_timeout) # ========================================================================= # COMMAND BUILDERS @@ -231,7 +253,7 @@ def _build_syringe_dispense_command( pre_dispense: bool = False, pre_dispense_volume: float = 0.0, num_pre_dispenses: int = 2, - column_mask: list[int] | None = None, + column_mask: Optional[list[int]] = None, ) -> bytes: """Build syringe dispense command bytes. diff --git a/pylabrobot/agilent/biotek/loading_tray_backend.py b/pylabrobot/agilent/biotek/loading_tray_backend.py new file mode 100644 index 00000000000..63207f0509d --- /dev/null +++ b/pylabrobot/agilent/biotek/loading_tray_backend.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import Optional + +from pylabrobot.agilent.biotek.plate_readers.base import BioTekBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray.backend import LoadingTrayBackend + + +class BioTekLoadingTrayBackend(LoadingTrayBackend): + """Loading tray backend that delegates to a BioTek serial driver.""" + + @dataclass + class OpenParams(BackendParams): + slow: bool = False + + @dataclass + class CloseParams(BackendParams): + slow: bool = False + + def __init__(self, driver: BioTekBackend): + self._driver = driver + + async def open(self, backend_params: Optional[BackendParams] = None): + if not isinstance(backend_params, self.OpenParams): + backend_params = self.OpenParams() + await self._driver._set_slow_mode(backend_params.slow) + await self._driver.send_command("J") + + async def close(self, backend_params: Optional[BackendParams] = None): + if not isinstance(backend_params, self.CloseParams): + backend_params = self.CloseParams() + await self._driver._set_slow_mode(backend_params.slow) + await self._driver.send_command("A") diff --git a/pylabrobot/agilent/biotek/plate_readers/__init__.py b/pylabrobot/agilent/biotek/plate_readers/__init__.py new file mode 100644 index 00000000000..eccf8aa96cb --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/__init__.py @@ -0,0 +1,3 @@ +from .base import BioTekBackend +from .cytation import Cytation1, Cytation5, CytationImagingConfig, CytationMicroscopyBackend +from .synergy import SynergyH1, SynergyH1Backend diff --git a/pylabrobot/plate_reading/agilent/biotek_backend.py b/pylabrobot/agilent/biotek/plate_readers/base.py similarity index 74% rename from pylabrobot/plate_reading/agilent/biotek_backend.py rename to pylabrobot/agilent/biotek/plate_readers/base.py index d119779ba7c..31f96180a89 100644 --- a/pylabrobot/plate_reading/agilent/biotek_backend.py +++ b/pylabrobot/agilent/biotek/plate_readers/base.py @@ -2,27 +2,49 @@ import enum import logging import time +from abc import ABCMeta +from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceBackend, AbsorbanceResult +from pylabrobot.capabilities.plate_reading.fluorescence import ( + FluorescenceBackend, + FluorescenceResult, +) +from pylabrobot.capabilities.plate_reading.luminescence import ( + LuminescenceBackend, + LuminescenceResult, +) +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver from pylabrobot.io.ftdi import FTDI -from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well +from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) -class BioTekPlateReaderBackend(PlateReaderBackend): +class BioTekBackend( + AbsorbanceBackend, + LuminescenceBackend, + FluorescenceBackend, + TemperatureControllerBackend, + Driver, + metaclass=ABCMeta, +): """Backend 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="Biotek Cytation 5") + self.io = FTDI(device_id=device_id, human_readable_device_name=human_readable_device_name) self._version: Optional[str] = None @@ -34,43 +56,20 @@ 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. - - Example: - >>> points = [ - >>> (1, 1), - >>> (2, 2), (2, 3), (2, 4), - >>> (3, 2), (3, 3), (3, 4), - >>> (4, 2), (4, 3), (4, 4), (4, 5), - >>> (5, 2), (5, 3), (5, 4), (5, 5), - >>> (6, 2), (6, 3), (6, 4), (6, 5), - >>> (7, 2), (7, 3), (7, 4), - >>> ] - >>> non_overlapping_rectangles(points) - [ - (1, 1, 1, 1), - (2, 2, 7, 4), - (4, 5, 6, 5), - ] - """ - + """Find non-overlapping rectangles that cover all given points.""" pts = set(points) rects = [] while pts: - # start a rectangle from one arbitrary point r0, c0 = min(pts) - # expand right c1 = c0 while (r0, c1 + 1) in pts: c1 += 1 - # expand downward as long as entire row segment is filled r1 = r0 while all((r1 + 1, c) in pts for c in range(c0, c1 + 1)): r1 += 1 rects.append((r0, c0, r1, c1)) - # remove covered points for r in range(r0, r1 + 1): for c in range(c0, c1 + 1): pts.discard((r, c)) @@ -78,30 +77,31 @@ def _non_overlapping_rectangles( rects.sort() return rects - async def setup(self) -> None: + async def setup(self, backend_params: Optional[BackendParams] = None) -> 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) # 0x38 0x41 - await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity + 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) - # see if we need to adjust baudrate. This appears to be the case sometimes. try: - self._version = await self.get_firmware_version() + self._version = await self.request_firmware_version() except TimeoutError: - await self.io.set_baudrate(38_461) # 4e c0 - self._version = await self.get_firmware_version() + 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(f"{self.__class__.__name__} stopping") + logger.info("[BioTek %s] disconnected", self.io.device_id) await self.stop_shaking() await self.io.stop() @@ -114,19 +114,19 @@ def version(self) -> str: return self._version @property - def abs_wavelength_range(self) -> tuple[int, int]: + def abs_wavelength_range(self) -> tuple: return (230, 999) @property - def focal_height_range(self) -> tuple[float, float]: + def focal_height_range(self) -> tuple: return (4.5, 13.88) @property - def excitation_range(self) -> tuple[int, int]: + def excitation_range(self) -> tuple: return (250, 700) @property - def emission_range(self) -> tuple[int, int]: + def emission_range(self) -> tuple: return (250, 700) @property @@ -137,24 +137,22 @@ def supports_heating(self) -> bool: 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]]: - """Return (min_temp, max_temp). - If cooling is not supported (heating only), min_temp is None. - If heating is not supported (cooling only), max_temp is None. - """ - max_temp = 45.0 if self.supports_heating else None # default BioTek max - min_temp = 4.0 if self.supports_cooling else None # default cooling minimum + 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: - """Purge the RX and TX buffers, as implemented in Gen5.exe""" 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, use self.timeout""" if timeout is None: timeout = self.timeout x = None @@ -199,12 +197,12 @@ async def send_command( return response - async def get_serial_number(self) -> str: + 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 get_firmware_version(self) -> str: + 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]) @@ -219,10 +217,8 @@ async def open(self, slow: bool = False): await self._set_slow_mode(slow) return await self.send_command("J") - async def close(self, plate: Optional[Plate], slow: bool = False): - # reset cache + async def close(self, plate: Optional[Plate] = None, slow: bool = False): self._plate = None - await self._set_slow_mode(slow) if plate is not None: await self.set_plate(plate) @@ -231,19 +227,19 @@ async def close(self, plate: Optional[Plate], slow: bool = False): async def home(self): return await self.send_command("i", "x") - async def get_current_temperature(self) -> float: - """Get current temperature in degrees Celsius.""" + async def request_current_temperature(self) -> float: resp = await self.send_command("h", timeout=1) assert resp is not None - return int(resp[1:-1]) / 100000 + 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): - """Set temperature in degrees Celsius.""" 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.get_current_temperature() + 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( @@ -261,6 +257,9 @@ async def set_temperature(self, temperature: float): 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 @@ -276,8 +275,8 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: for group in grouped_values: assert len(group) == 3 - row_index = int(group[0].decode()) - 1 # 1-based index in the response - column_index = int(group[1].decode()) - 1 # 1-based index in the response + 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 @@ -290,17 +289,6 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: return result async def set_plate(self, plate: Plate): - # 08120112207434014351135308559127881422 - # ^^^^ plate size z - # ^^^^^ plate size x - # ^^^^^ plate size y - # ^^^^^ bottom right x - # ^^^^^ top left x - # ^^^^^ bottom right y - # ^^^^^ top left y - # ^^ columns - # ^^ rows - if plate is self._plate: return @@ -322,8 +310,8 @@ async def set_plate(self, plate: Plate): 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 # invert y axis - bottom_right_well_center_y = plate.get_size_y() - bottom_right_well_center.y # invert y axis + 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}" @@ -344,18 +332,30 @@ async def set_plate(self, plate: Plate): def _get_min_max_row_col_tuples( self, wells: List[Well], plate: Plate - ) -> List[Tuple[int, int, int, int]]: # min_row, min_col, max_row, max_col - # check if all wells are in the same 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[Dict]: + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> 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) @@ -372,38 +372,59 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int resp = await self.send_command("O") assert resp == b"\x060000\x03" - # read data body = await self._read_until(b"\x03", timeout=60 * 3) assert body is not None parsed_data = self._parse_body(body) - # Merge data 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] - # Get current temperature try: - temp = await self.get_current_temperature() + temp = await self.request_current_temperature() except TimeoutError: - temp = float("nan") + temp = None return [ - { - "wavelength": wavelength, - "data": all_data, - "temperature": temp, - "time": time.time(), - } + AbsorbanceResult( + wavelength=wavelength, + data=all_data, + temperature=temp, + timestamp=time.time(), + ) ] + @dataclass + class LuminescenceParams(BackendParams): + """BioTek-specific parameters for luminescence reads. + + Args: + integration_time: Integration time in seconds. Default 1. + """ + + integration_time: float = 1 + async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 - ) -> List[Dict]: + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = BioTekBackend.LuminescenceParams() + + integration_time = backend_params.integration_time 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" @@ -412,8 +433,6 @@ async def read_luminescence( 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) - # TODO: I don't know if the multiple of 0.2 is a firmware requirement, but it's what gen5.exe requires. - # round because of floating point precision issues assert round(integration_time_milliseconds * 10) % 2 == 0, ( "Integration time milliseconds must be a multiple of 0.2" ) @@ -424,38 +443,34 @@ async def read_luminescence( [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" # 0812 - checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8 + 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" - # 2m10s of reading per 1 second of integration time - # allow 60 seconds flat 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) - # Merge data 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] - # Get current temperature try: - temp = await self.get_current_temperature() + temp = await self.request_current_temperature() except TimeoutError: - temp = float("nan") + temp = None return [ - { - "data": all_data, - "temperature": temp, - "time": time.time(), - } + LuminescenceResult( + data=all_data, + temperature=temp, + timestamp=time.time(), + ) ] async def read_fluorescence( @@ -465,7 +480,8 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[Dict]: + backend_params: Optional[SerializableMixin] = None, + ) -> 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}") @@ -480,6 +496,14 @@ async def read_fluorescence( 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" @@ -496,7 +520,7 @@ async def read_fluorescence( 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) # don't know why +7 + checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) cmd = cmd + checksum + "\x03" await self.send_command("D", cmd) @@ -506,26 +530,24 @@ async def read_fluorescence( body = await self._read_until(b"\x03", timeout=60 * 2) assert body is not None parsed_data = self._parse_body(body) - # Merge data 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] - # Get current temperature try: - temp = await self.get_current_temperature() + temp = await self.request_current_temperature() except TimeoutError: - temp = float("nan") + temp = None return [ - { - "ex_wavelength": excitation_wavelength, - "em_wavelength": emission_wavelength, - "data": all_data, - "temperature": temp, - "time": time.time(), - } + FluorescenceResult( + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + data=all_data, + temperature=temp, + timestamp=time.time(), + ) ] async def _abort(self) -> None: @@ -536,27 +558,22 @@ class ShakeType(enum.IntEnum): ORBITAL = 1 async def shake(self, shake_type: ShakeType, frequency: int) -> None: - """Warning: the duration for shaking has to be specified on the machine, and the maximum is - 16 minutes. As a hack, we start shaking for the maximum duration every time as long as stop - is not called. I think the machine might open the door at the end of the 16 minutes and then - move it back in. We have to find a way to shake continuously, which is possible in protocol-mode - with kinetics. + """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 + 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 # 16 minutes + max_duration = 16 * 60 self._shaking_started = asyncio.Event() async def shake_maximal_duration(): - """This method will start the shaking, but returns immediately after - shaking has started.""" 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) # don't know why +73 + checksum = str((sum(cmd.encode()) + 73) % 100).zfill(2) cmd = cmd + checksum + "\x03" await self.send_command("D", cmd) @@ -570,7 +587,6 @@ async def shake_continuous(): while self._shaking: await shake_maximal_duration() - # short sleep allows = frequent checks for fast stopping seconds_since_start: float = 0 loop_wait_time = 0.25 while seconds_since_start < max_duration and self._shaking: @@ -591,6 +607,5 @@ async def stop_shaking(self) -> None: try: await self._shaking_task except asyncio.CancelledError: - # Task cancellation is expected here; safe to ignore this exception. pass self._shaking_task = None diff --git a/pylabrobot/agilent/biotek/plate_readers/cytation/__init__.py b/pylabrobot/agilent/biotek/plate_readers/cytation/__init__.py new file mode 100644 index 00000000000..39f48476b3c --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/cytation/__init__.py @@ -0,0 +1,3 @@ +from .cytation1 import Cytation1 +from .cytation5 import Cytation5 +from .microscopy_backend import CytationImagingConfig, CytationMicroscopyBackend diff --git a/pylabrobot/agilent/biotek/plate_readers/cytation/aravis_camera.py b/pylabrobot/agilent/biotek/plate_readers/cytation/aravis_camera.py new file mode 100644 index 00000000000..1f9cdd8c435 --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/cytation/aravis_camera.py @@ -0,0 +1,452 @@ +"""Standalone BlackFly camera driver using Aravis (GenICam/USB3 Vision). + +Layer: Camera driver (standalone, no PLR dependencies except numpy) +Adjacent layers: + - Above: CytationMicroscopyBackend delegates camera operations here + - Below: Aravis library (via PyGObject) talks to camera via USB3 Vision/GenICam + +Aravis talks directly to the camera via the GenICam standard over USB3 Vision. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import 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: + import gi + + gi.require_version("Aravis", "0.8") + from gi.repository import Aravis # type: ignore[attr-defined] + + 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[object] = None # Aravis.Camera + self._device: Optional[object] = None # Aravis.Device + self._stream: Optional[object] = 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 + + 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. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if self._acquiring: + return + self._camera.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """End camera acquisition (no-op if not acquiring). + + Stop camera acquisition. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if not self._acquiring: + return + self._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. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + 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). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + # 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 + self._camera.set_exposure_time(exposure_us) + + async def get_exposure(self) -> float: + """Read current exposure time in milliseconds (from hardware, not cached).""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + exposure_us = self._camera.get_exposure_time() + return 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). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + self._device.set_string_feature_value("GainAuto", "Off") + self._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. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + 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).""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return 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. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + 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. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if fmt is None: + if HAS_ARAVIS: + fmt = Aravis.PIXEL_FORMAT_MONO_8 + else: + return + self._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. + """ + if self._device is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return CameraInfo( + serial_number=self._device.get_string_feature_value("DeviceSerialNumber"), + model_name=self._device.get_string_feature_value("DeviceModelName"), + vendor=self._device.get_string_feature_value("DeviceVendorName"), + firmware_version=self._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/plate_readers/cytation/base.py b/pylabrobot/agilent/biotek/plate_readers/cytation/base.py new file mode 100644 index 00000000000..4df9354aa3c --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/cytation/base.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability +from pylabrobot.capabilities.loading_tray import LoadingTray +from pylabrobot.capabilities.microscopy import Microscopy +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from pylabrobot.agilent.biotek.plate_readers.base import BioTekBackend +from pylabrobot.agilent.biotek.loading_tray_backend import BioTekLoadingTrayBackend + +from .microscopy_backend import CytationImagingConfig, CytationMicroscopyBackend + +logger = logging.getLogger(__name__) + + +class _CytationBase(Resource, Device): + """Shared base for Cytation 1 and Cytation 5 devices. + + Handles driver creation, resource init, plate holder, lifecycle (stop/open/close), + and capability wiring for microscopy, temperature, and loading tray + (common to both models). Subclasses add model-specific capabilities in setup(). + """ + + _model_name: str # set by subclass + + def __init__( + self, + name: str, + camera_serial: Optional[str] = None, + device_id: Optional[str] = None, + imaging_config: Optional[CytationImagingConfig] = None, + use_cam: bool = True, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + driver = BioTekBackend( + device_id=device_id, + human_readable_device_name=self._model_name, + ) + + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model=self._model_name, + ) + Device.__init__(self, driver=driver) + self.driver: BioTekBackend = driver + + self._microscopy_backend = CytationMicroscopyBackend( + driver=driver, + camera_serial=camera_serial, + imaging_config=imaging_config, + use_cam=use_cam, + ) + + self.microscopy: Microscopy # set in _setup_base() + self.temperature: TemperatureController # set in _setup_base() + self.loading_tray: LoadingTray # set in _setup_base() + self._capabilities: List[Capability] = [] + + 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(), + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + async def _setup_base(self) -> None: + """Set up driver and wire shared capabilities.""" + await self.driver.setup() + + self.microscopy = Microscopy(backend=self._microscopy_backend) + self.temperature = TemperatureController(backend=self.driver) + self.loading_tray = LoadingTray( + backend=BioTekLoadingTrayBackend(driver=self.driver), + name=self.name + "_loading_tray", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + + async def stop(self) -> None: + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + logger.info("%s stopped", self.__class__.__name__) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self.loading_tray.open( + backend_params=BioTekLoadingTrayBackend.OpenParams(slow=slow) + ) + + async def close(self, slow: bool = False) -> None: + await self.loading_tray.close( + backend_params=BioTekLoadingTrayBackend.CloseParams(slow=slow) + ) diff --git a/pylabrobot/agilent/biotek/plate_readers/cytation/cytation1.py b/pylabrobot/agilent/biotek/plate_readers/cytation/cytation1.py new file mode 100644 index 00000000000..6852c13094b --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/cytation/cytation1.py @@ -0,0 +1,40 @@ +"""Cytation 1 device — imager with temperature control. + +Example:: + + cytation = Cytation1(name="cytation1", camera_serial="22580842") + + await cytation.setup() + result = await cytation.microscopy.capture(...) + await cytation.stop() +""" + +from __future__ import annotations + +import logging + +from .base import _CytationBase + +logger = logging.getLogger(__name__) + + +class Cytation1(_CytationBase): + """Agilent BioTek Cytation 1 — imager with temperature control. + + Capabilities: + - microscopy (imaging) + - temperature (incubation) + - loading tray + """ + + _model_name = "Agilent BioTek Cytation 1" + + async def setup(self) -> None: + await self._setup_base() + + self._capabilities = [self.microscopy, self.temperature, self.loading_tray] + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + logger.info("Cytation1 setup complete") diff --git a/pylabrobot/agilent/biotek/plate_readers/cytation/cytation5.py b/pylabrobot/agilent/biotek/plate_readers/cytation/cytation5.py new file mode 100644 index 00000000000..a9491fb48ca --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/cytation/cytation5.py @@ -0,0 +1,56 @@ +"""Cytation 5 device — plate reader + imager. + +Example:: + + cytation = Cytation5(name="cytation5", camera_serial="22580842") + + await cytation.setup() + result = await cytation.microscopy.capture(...) + await cytation.stop() +""" + +from __future__ import annotations + +import logging + +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence + +from .base import _CytationBase + +logger = logging.getLogger(__name__) + + +class Cytation5(_CytationBase): + """Agilent BioTek Cytation 5 — plate reader + imager. + + Capabilities: + - absorbance, fluorescence, luminescence (plate reading) + - microscopy (imaging) + - temperature (incubation) + - loading tray + """ + + _model_name = "Agilent BioTek Cytation 5" + + async def setup(self) -> None: + await self._setup_base() + + self.absorbance = Absorbance(backend=self.driver) + self.luminescence = Luminescence(backend=self.driver) + self.fluorescence = Fluorescence(backend=self.driver) + + self._capabilities = [ + self.absorbance, + self.luminescence, + self.fluorescence, + self.microscopy, + self.temperature, + self.loading_tray, + ] + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + logger.info("Cytation5 setup complete") diff --git a/pylabrobot/agilent/biotek/plate_readers/cytation/microscopy_backend.py b/pylabrobot/agilent/biotek/plate_readers/cytation/microscopy_backend.py new file mode 100644 index 00000000000..25dbe530920 --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/cytation/microscopy_backend.py @@ -0,0 +1,612 @@ +"""CytationMicroscopyBackend — MicroscopyBackend for the Cytation. + +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). + +Uses the BioTekBackend's serial connection (send_command) for optics commands. + +Layer: Capability backend +Adjacent layers: + - Above: Microscopy capability calls capture() + - Below: BioTekBackend (serial IO) + 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.capabilities.capability import BackendParams +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin + +from .aravis_camera import AravisCamera + +if TYPE_CHECKING: + from pylabrobot.agilent.biotek.plate_readers.base import BioTekBackend + +logger = logging.getLogger(__name__) + + +@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 CytationMicroscopyBackend(MicroscopyBackend): + """MicroscopyBackend for the Cytation. + + Owns the AravisCamera and all imaging state. Uses the BioTekBackend's + serial connection for optics commands. + """ + + def __init__( + self, + driver: BioTekBackend, + 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, backend_params: Optional[BackendParams] = None) -> 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 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 == ImagingMode.BRIGHTFIELD or mode == ImagingMode.PHASE_CONTRAST: + 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: + await self.led_on(intensity=led_intensity) + return + + if mode == ImagingMode.COLOR_BRIGHTFIELD: + raise NotImplementedError("Color brightfield not implemented") + + 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.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 == ImagingMode.BRIGHTFIELD: + 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") + + self._imaging_mode = mode + 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 Microscopy 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) + + # ─── Vendor Params ────────────────────────────────────────────────── + + @dataclass + class CaptureParams(BackendParams): + """Cytation-specific parameters for image capture. + + Args: + led_intensity: LED intensity (1-10). Default 10. + coverage: Image tiling coverage. ``"full"`` for full-well montage, or a + ``(rows, cols)`` tuple for a specific tile grid. Default ``(1, 1)`` (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. Default None. + 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. Default True. + """ + + 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 + + # ─── MicroscopyBackend.capture() ───────────────────────────────────── + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + if not isinstance(backend_params, self.CaptureParams): + backend_params = CytationMicroscopyBackend.CaptureParams() + + led_intensity = backend_params.led_intensity + coverage = backend_params.coverage + center_position = backend_params.center_position + overlap = backend_params.overlap + auto_stop_acquisition = backend_params.auto_stop_acquisition + + 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 == 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() + 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/plate_readers/synergy/__init__.py b/pylabrobot/agilent/biotek/plate_readers/synergy/__init__.py new file mode 100644 index 00000000000..52c4f2b80fd --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/synergy/__init__.py @@ -0,0 +1 @@ +from .synergy_h1 import SynergyH1, SynergyH1Backend diff --git a/pylabrobot/agilent/biotek/plate_readers/synergy/synergy_h1.py b/pylabrobot/agilent/biotek/plate_readers/synergy/synergy_h1.py new file mode 100644 index 00000000000..9d49e4d945e --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_readers/synergy/synergy_h1.py @@ -0,0 +1,152 @@ +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_readers.base import BioTekBackend +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +logger = logging.getLogger(__name__) + + +class SynergyH1Backend(BioTekBackend): + """Backend for Agilent BioTek Synergy H1 plate readers.""" + + def __init__(self, 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" + ) + + @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 + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class SynergyH1(Resource, Device): + """Agilent BioTek Synergy H1 plate reader.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = SynergyH1Backend(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent BioTek Synergy H1", + ) + Device.__init__(self, driver=backend) + self.driver: SynergyH1Backend = backend + self.absorbance = Absorbance(backend=backend) + self.luminescence = Luminescence(backend=backend) + self.fluorescence = Fluorescence(backend=backend) + self.temperature = TemperatureController(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.temperature] + + 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 + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self.driver.open(slow=slow) + + async def close(self, slow: bool = False) -> None: + await self.driver.close(slow=slow) diff --git a/pylabrobot/agilent/vspin/__init__.py b/pylabrobot/agilent/vspin/__init__.py new file mode 100644 index 00000000000..52a4b6f576f --- /dev/null +++ b/pylabrobot/agilent/vspin/__init__.py @@ -0,0 +1 @@ +from .vspin import Access2, Access2Driver, VSpin, VSpinCentrifugeBackend, VSpinDriver diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py new file mode 100644 index 00000000000..520f6bc3a95 --- /dev/null +++ b/pylabrobot/agilent/vspin/vspin.py @@ -0,0 +1,755 @@ +import asyncio +import ctypes +import json +import logging +import math +import os +import time +import warnings +from dataclasses import dataclass +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.centrifuging import Centrifuge +from pylabrobot.capabilities.centrifuging import CentrifugeBackend as _NewCentrifugeBackend +from pylabrobot.capabilities.centrifuging.errors import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) +from pylabrobot.device import Device, Driver +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Coordinate, Resource, ResourceHolder +from pylabrobot.serializer import SerializableMixin + +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 VSpinDriver(Driver): + """FTDI driver for the Agilent VSpin Centrifuge. + + Owns the USB connection, low-level command protocol, and hardware status queries. + """ + + def __init__(self, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using + `python -m pylibftdi.examples.list_devices` + """ + super().__init__() + self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) + self.device_id = device_id + + async def setup(self, backend_params: Optional[BackendParams] = None): + 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) + + 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) -> _StatusPositionTachometer: + resp = await self.send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpinDriver._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 + + +# --------------------------------------------------------------------------- +# VSpin Centrifuge Backend — protocol translation +# --------------------------------------------------------------------------- + + +class VSpinCentrifugeBackend(_NewCentrifugeBackend): + """Translates CentrifugeBackend interface into VSpin driver commands.""" + + def __init__(self, driver: VSpinDriver): + self.driver = driver + self._bucket_1_remainder: Optional[int] = None + if driver.device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(driver.device_id) + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + driver = self.driver + + await driver.send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await driver.send_command(bytes.fromhex("aa0220ff0f30")) + await driver.send_command(bytes.fromhex("aa0220df0f10")) + await driver.send_command(bytes.fromhex("aa0220df0e0f")) + await driver.send_command(bytes.fromhex("aa0220df0c0d")) + await driver.send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await driver.send_command(bytes.fromhex("aa0226000028")) + await driver.send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await driver.send_command(bytes.fromhex("aa0226200048")) + await driver.send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() + + await driver.send_command(bytes.fromhex("aa0226000028")) + + await driver.send_command(bytes.fromhex("aa0117021a")) + await driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await driver.send_command(bytes.fromhex("aa0117041c")) + await driver.send_command(bytes.fromhex("aa01170119")) + + await driver.send_command(bytes.fromhex("aa010b0c")) + await driver.send_command(bytes.fromhex("aa010001")) + await driver.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await driver.send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await driver.send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await driver.request_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await driver.send_command(bytes.fromhex("aa0117021a")) + await driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await driver.send_command(bytes.fromhex("aa0117041c")) + await driver.send_command(bytes.fromhex("aa01170119")) + + await driver.send_command(bytes.fromhex("aa010b0c")) + await driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") + await driver.send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await driver.request_positions_and_tachometer()).status + + await driver.send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + if self._bucket_1_remainder is None: + device_id = await driver.io.request_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + # -- 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.driver.request_position() + device_id = await self.driver.io.request_serial() + remainder = await self.driver.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.driver.request_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + current_position = await self.driver.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.driver.request_door_open(): + return + logger.info("[vSpin %s] open door", self.driver.device_id) + await self.driver.send_command(bytes.fromhex("aa022600062e")) + await asyncio.sleep(4) + + async def close_door(self): + if not (await self.driver.request_door_open()): + return + logger.info("[vSpin %s] close door", self.driver.device_id) + await self.driver.send_command(bytes.fromhex("aa022600042c")) + await asyncio.sleep(2) + + async def lock_door(self): + if await self.driver.request_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.driver.request_door_locked(): + return + logger.info("[vSpin %s] lock door", self.driver.device_id) + await self.driver.send_command(bytes.fromhex("aa0226000028")) + + async def unlock_door(self): + if not await self.driver.request_door_locked(): + return + await self.driver.send_command(bytes.fromhex("aa022600042c")) + + async def lock_bucket(self): + if await self.driver.request_bucket_locked(): + return + await self.driver.send_command(bytes.fromhex("aa022600072f")) + + async def unlock_bucket(self): + if not await self.driver.request_bucket_locked(): + return + await self.driver.send_command(bytes.fromhex("aa022600062e")) + + async def go_to_bucket1(self): + await self.go_to_position(await self.request_bucket_1_position()) + + async def go_to_bucket2(self): + await self.go_to_position(await self.request_bucket_1_position() + FULL_ROTATION // 2) + + async def go_to_position(self, position: int): + logger.info("[vSpin %s] go_to_position: position=%d", self.driver.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.driver.send_command(bytes.fromhex("aa0226000028")) + await self.driver.send_command(bytes.fromhex("aa0117021a")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(bytes.fromhex("aa0117041c")) + await self.driver.send_command(bytes.fromhex("aa01170119")) + await self.driver.send_command(bytes.fromhex("aa010b0c")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(byte_string) + + while abs(await self.driver.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 + + @dataclass + class SpinParams(BackendParams): + """VSpin centrifuge parameters for spin operations. + + Args: + acceleration: Acceleration rate as a fraction of maximum (0 to 1, exclusive of 0). + Default 0.8. + deceleration: Deceleration rate as a fraction of maximum (0 to 1, exclusive of 0). + Default 0.8. + """ + + acceleration: float = 0.8 + deceleration: float = 0.8 + + async def spin( + self, + g: float = 500, + duration: float = 60, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + """Start a spin cycle. + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + backend_params: VSpinCentrifugeBackend.SpinParams with acceleration and deceleration (0-1). + """ + if not isinstance(backend_params, self.SpinParams): + backend_params = VSpinCentrifugeBackend.SpinParams() + + acceleration = backend_params.acceleration + deceleration = backend_params.deceleration + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.driver.request_door_open(): + await self.close_door() + if not await self.driver.request_door_locked(): + await self.lock_door() + if await self.driver.request_bucket_locked(): + await self.unlock_bucket() + + rpm = VSpinCentrifugeBackend.g_to_rpm(g) + logger.info( + "[vSpin %s] spin: g=%.1f rpm=%d duration=%.1fs acceleration=%.2f deceleration=%.2f", + self.driver.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.driver.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.driver.send_command(bytes.fromhex("aa0226000028")) + await self.driver.send_command(bytes.fromhex("aa0117021a")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(bytes.fromhex("aa0117041c")) + await self.driver.send_command(bytes.fromhex("aa01170119")) + await self.driver.send_command(bytes.fromhex("aa010b0c")) + await self.driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + + await self.driver.send_command(byte_string) + + while ( + await self.driver.request_tachometer() < rpm * 0.95 + and await self.driver.request_position() < final_position + ): + await asyncio.sleep(0.1) + + if await self.driver.request_position() < final_position: + decel_start_position = await self.driver.request_position() + distance_at_speed + + while await self.driver.request_position() < decel_start_position: + await asyncio.sleep(0.1) + + await self.driver.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.driver.send_command(decel_command) + + await asyncio.sleep(2) + + async def _reset_to_zero(): + await self.driver.send_command(bytes.fromhex("aa0117021a")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(bytes.fromhex("aa0117041c")) + await self.driver.send_command(bytes.fromhex("aa01170119")) + await self.driver.send_command(bytes.fromhex("aa010b0c")) + await self.driver.send_command(bytes.fromhex("aa010001")) + await self.driver.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self.driver.send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self.driver.send_command(bytes.fromhex("aa01192842")) + + await _reset_to_zero() + + start = await self.driver.request_home_position() + num_tries = 0 + while await self.driver.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.") + + +# --------------------------------------------------------------------------- +# Access2 Driver — pure driver, no capabilities +# --------------------------------------------------------------------------- + + +class Access2Driver(Driver): + """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, backend_params: Optional[BackendParams] = None): + 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")) + + +# --------------------------------------------------------------------------- +# Devices +# --------------------------------------------------------------------------- + + +class VSpin(Resource, Device): + """Agilent VSpin Centrifuge.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + driver = VSpinDriver(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent VSpin", + category="centrifuge", + ) + Device.__init__(self, driver=driver) + self.driver: VSpinDriver = driver + + bucket1 = ResourceHolder( + name=f"{name}_bucket1", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + bucket2 = ResourceHolder( + name=f"{name}_bucket2", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + self.assign_child_resource(bucket1, location=Coordinate.zero()) + self.assign_child_resource(bucket2, location=Coordinate.zero()) + + self.centrifuging = Centrifuge( + backend=VSpinCentrifugeBackend(driver), buckets=(bucket1, bucket2) + ) + self._capabilities = [self.centrifuging] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + +class Access2(ResourceHolder, Device): + """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(), + ) + Device.__init__(self, driver=driver) + self.driver: Access2Driver = driver + self._vspin = vspin + + def serialize(self) -> dict: + return {**ResourceHolder.serialize(self), **Device.serialize(self)} + + async def load(self) -> None: + centrifuging = self._vspin.centrifuging + + if not centrifuging.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to load a plate.") + if centrifuging.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to load a plate. " + "Use centrifuging.go_to_bucket1() or centrifuging.go_to_bucket2()." + ) + if self.resource is None: + raise LoaderNoPlateError("Loader must have a plate to load.") + if centrifuging.at_bucket.resource is not None: + raise BucketHasPlateError("Bucket must be empty to load a plate.") + + await self.driver.load() + + centrifuging.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) + + async def unload(self) -> None: + centrifuging = self._vspin.centrifuging + + if not centrifuging.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to unload a plate.") + if centrifuging.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to unload a plate. " + "Use centrifuging.go_to_bucket1() or centrifuging.go_to_bucket2()." + ) + if centrifuging.at_bucket.resource is None: + raise BucketNoPlateError("Bucket must have a plate to unload.") + + await self.driver.unload() + + self.assign_child_resource(centrifuging.at_bucket.resource) diff --git a/pylabrobot/agrowpumps/__init__.py b/pylabrobot/agrowpumps/__init__.py new file mode 100644 index 00000000000..122f9c721d3 --- /dev/null +++ b/pylabrobot/agrowpumps/__init__.py @@ -0,0 +1 @@ +from .agrowdosepump_backend import AgrowChannelBackend, AgrowDosePumpArray, AgrowDriver diff --git a/pylabrobot/agrowpumps/agrowdosepump_backend.py b/pylabrobot/agrowpumps/agrowdosepump_backend.py new file mode 100644 index 00000000000..00cb18d6da5 --- /dev/null +++ b/pylabrobot/agrowpumps/agrowdosepump_backend.py @@ -0,0 +1,221 @@ +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 + +from pylabrobot.capabilities.capability import BackendParams, Capability +from pylabrobot.capabilities.pumping.backend import PumpBackend +from pylabrobot.capabilities.pumping.calibration import PumpCalibration +from pylabrobot.capabilities.pumping.pumping import Pump +from pylabrobot.device import Device, Driver + +logger = logging.getLogger(__name__) + + +class AgrowDriver(Driver): + """Modbus driver for Agrow dose pump arrays.""" + + 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 + + @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, backend_params: Optional[BackendParams] = None): + 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)} + 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 AgrowChannelBackend(PumpBackend): + """Per-channel PumpBackend adapter that delegates to a shared AgrowDriver.""" + + def __init__(self, connection: AgrowDriver, channel: int): + self.driver = connection + 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, + } + + +class AgrowDosePumpArray(Device): + """Agrow dose pump array device. + + Exposes each channel as an individual Pump via `self.pumps`. + """ + + def __init__( + self, + port: str, + address: Union[int, str], + calibrations: Optional[List[Optional[PumpCalibration]]] = None, + ): + self._channel_backends: List[AgrowChannelBackend] = [] + self.pumps: List[Pump] = [] + self._calibrations = calibrations + super().__init__(driver=AgrowDriver(port=port, address=address)) + self.driver: AgrowDriver + + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.driver.setup(backend_params=backend_params) + num_channels = self.driver.num_channels + + self._channel_backends = [AgrowChannelBackend(self.driver, ch) for ch in range(num_channels)] + self.pumps = [] + for i, backend in enumerate(self._channel_backends): + cal = None + if self._calibrations is not None and i < len(self._calibrations): + cal = self._calibrations[i] + cap = Pump(backend=backend, calibration=cal) + self.pumps.append(cap) + + self._capabilities: List[Capability] = list(self.pumps) + for c in self._capabilities: + await c._on_setup() + self._setup_finished = True + + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + + def serialize(self): + return { + "port": self.driver.port, + "address": self.driver.address, + } diff --git a/pylabrobot/agrowpumps/agrowdosepump_tests.py b/pylabrobot/agrowpumps/agrowdosepump_tests.py new file mode 100644 index 00000000000..5c53861b2ef --- /dev/null +++ b/pylabrobot/agrowpumps/agrowdosepump_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 AgrowDosePumpArray + + +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 = AgrowDosePumpArray(port="simulated", address=1) + + async def _mock_setup_modbus(): + self.device.driver._modbus = SimulatedModbusClient() + + with patch.object(self.device.driver, "_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.driver.port, "simulated") + self.assertEqual(self.device.driver.address, 1) + self.assertEqual(len(self.device.pumps), 6) + self.assertEqual( + self.device.driver._pump_index_to_address, + {pump: pump + 100 for pump in range(0, 6)}, + ) + + async def test_run_continuously(self): + self.device.driver.modbus.write_register.reset_mock() + await self.device.pumps[0].run_continuously(speed=1) + self.device.driver.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.pumps[0].run_continuously(speed=101) + + async def test_run_revolutions(self): + with self.assertRaises(NotImplementedError): + await self.device.pumps[0].run_revolutions(num_revolutions=1.0) + + async def test_halt_single_channel(self): + self.device.driver.modbus.write_register.reset_mock() + await self.device.pumps[2].halt() + self.device.driver.modbus.write_register.assert_called_once_with(102, 0, unit=1) diff --git a/pylabrobot/arms/__init__.py b/pylabrobot/arms/__init__.py index 60125af2e6e..41f1ad785ce 100644 --- a/pylabrobot/arms/__init__.py +++ b/pylabrobot/arms/__init__.py @@ -1,3 +1,9 @@ -from .precise_flex import * -from .scara import * -from .standard import * +import warnings + +warnings.warn( + "Importing from pylabrobot.arms is deprecated. Use pylabrobot.capabilities.arms instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.capabilities.arms import * # noqa: F401,F403,E402 diff --git a/pylabrobot/arms/architecture.md b/pylabrobot/arms/architecture.md new file mode 100644 index 00000000000..99ccf47d5ee --- /dev/null +++ b/pylabrobot/arms/architecture.md @@ -0,0 +1,88 @@ +# Arms architecture + +## Frontend hierarchy (capabilities) + +``` +_BaseArm(Capability) + │ halt(), park(), get_gripper_location() + │ resource tracking (pick_up/drop state) + │ + └── GripperArm + │ open/close_gripper, is_gripper_closed + │ pick_up/drop/move at location + │ pick_up_resource(), drop_resource(), move_resource() (convenience) + │ + └── OrientableArm + Arm with rotation. E.g. Hamilton iSWAP, PreciseFlex. + pick_up/drop/move with direction parameter +``` + +Frontend mirrors backend hierarchy exactly. +Joint-space methods are backend-only (robot-specific), accessed via `arm.backend`. + +## Backend hierarchy (capability backends) + +``` +_BaseArmBackend(CapabilityBackend) + │ halt(), park(), get_gripper_location() + │ + ├── GripperArmBackend + │ open/close_gripper, is_gripper_closed + │ pick_up/drop/move at location (no rotation) + │ + ├── OrientableGripperArmBackend + │ pick_up/drop/move with direction (float degrees) + │ + └── ArticulatedGripperArmBackend + pick_up/drop/move with full Rotation +``` + +## Mixins (backend) + +- `HasJoints` — joint-space control: pick_up/drop/move at joint position, get_joint_position +- `CanFreedrive` — freedrive (manual guidance) mode + +## Concrete implementations + +| Device | Driver | Arm Backend | Frontend | +|--------|--------|-------------|----------| +| Hamilton STAR (iSWAP) | STARDriver (shared) | `iSWAP(OrientableGripperArmBackend)` | `OrientableArm` | +| Hamilton STAR (core) | STARDriver (shared) | `CoreGripper(GripperArmBackend)` | `Arm` | +| PreciseFlex 400 | `PreciseFlexDriver` | `PreciseFlexArmBackend(OrientableGripperArmBackend, HasJoints, CanFreedrive)` | `OrientableArm` | + +## Usage + +Arms are capabilities, not devices. They are owned by a Device: + +```python +class STAR(Device): + def __init__(self, ...): + driver = STARDriver(...) + super().__init__(driver=driver) + self.iswap = OrientableArm(backend=iSWAP(driver), reference_resource=deck) + self.core_gripper = GripperArm(backend=CoreGripper(driver), reference_resource=deck) + self._capabilities = [self.iswap, self.core_gripper] +``` + +A standalone arm (like PreciseFlex) is a Device with a single arm capability: + +```python +class PreciseFlex400(Device): + def __init__( + self, host, port=10100, has_rail=False, timeout=20, gripper_length=162.0, gripper_z_offset=0.0 + ): + driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + super().__init__(driver=driver) + backend = PreciseFlexArmBackend( + driver=driver, + has_rail=has_rail, + gripper_length=gripper_length, + gripper_z_offset=gripper_z_offset, + ) + self.arm = OrientableArm(backend=backend, reference_resource=self.reference) + self._capabilities = [self.arm] + +# Joint methods accessed via backend (robot-specific): +await pf.arm.backend.move_to_joint_position({1: 0, 2: 90, 3: 45}) +await pf.arm.backend.start_freedrive_mode(free_axes=[0]) +``` diff --git a/pylabrobot/arms/arm.py b/pylabrobot/arms/arm.py new file mode 100644 index 00000000000..b5b4154b806 --- /dev/null +++ b/pylabrobot/arms/arm.py @@ -0,0 +1,9 @@ +import warnings + +warnings.warn( + "Importing from pylabrobot.arms.arm is deprecated. Use pylabrobot.capabilities.arms.arm instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.capabilities.arms.arm import * # noqa: F401,F403,E402 diff --git a/pylabrobot/arms/arm_tests.py b/pylabrobot/arms/arm_tests.py new file mode 100644 index 00000000000..7860c30f343 --- /dev/null +++ b/pylabrobot/arms/arm_tests.py @@ -0,0 +1,181 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.arms.arm import GripperArm +from pylabrobot.arms.backend import ( + GripperArmBackend, + OrientableGripperArmBackend, +) +from pylabrobot.arms.orientable_arm import OrientableArm +from pylabrobot.arms.standard import GripDirection +from pylabrobot.resources import Coordinate, Resource, ResourceHolder + + +def _assert_location(test, call, x, y, z, places=1): + """Assert the location kwarg of a mock call matches expected coordinates.""" + loc = call.kwargs["location"] + test.assertAlmostEqual(loc.x, x, places=places) + test.assertAlmostEqual(loc.y, y, places=places) + test.assertAlmostEqual(loc.z, z, places=places) + + +def _make_deck_with_sites(): + """Create a fictional deck with two sites and a plate. + + Deck: 1000x1000x0 at origin. + Site A at (100, 100, 50), site B at (100, 300, 50). + Plate: 120x80x10 assigned to site A. + """ + deck = Resource("deck", size_x=1000, size_y=1000, size_z=0) + + site_a = ResourceHolder("site_a", size_x=130, size_y=90, size_z=0) + deck.assign_child_resource(site_a, location=Coordinate(100, 100, 50)) + + site_b = ResourceHolder("site_b", size_x=130, size_y=90, size_z=0) + deck.assign_child_resource(site_b, location=Coordinate(100, 300, 50)) + + plate = Resource("plate", size_x=120, size_y=80, size_z=10) + site_a.assign_child_resource(plate, location=Coordinate(5, 5, 0)) + + return deck, site_a, site_b, plate + + +class TestArm(unittest.IsolatedAsyncioTestCase): + """Test Arm (ArmBackend, no rotation). E.g. Hamilton core grippers.""" + + async def asyncSetUp(self): + self.mock_backend = MagicMock(spec=GripperArmBackend) + for method_name in [ + "pick_up_at_location", + "drop_at_location", + "move_to_location", + "open_gripper", + "close_gripper", + "is_gripper_closed", + "halt", + "park", + ]: + setattr(self.mock_backend, method_name, AsyncMock()) + + self.deck, self.site_a, self.site_b, self.plate = _make_deck_with_sites() + self.arm = GripperArm(backend=self.mock_backend, reference_resource=self.deck) + + async def test_pick_up_resource(self): + # plate at site_a(100,100,50) + child_loc(5,5,0), center_xy=(60,40), size_z=10 + # pickup_distance_from_bottom=8 → z = 50 + 8 = 58 + await self.arm.pick_up_resource(self.plate, pickup_distance_from_bottom=8) + call = self.mock_backend.pick_up_at_location.call_args + _assert_location(self, call, 165, 145, 58) + # default grip_axis="x" → resource_width is X size = 120 + self.assertAlmostEqual(call.kwargs["resource_width"], 120) + + async def test_drop_resource(self): + await self.arm.pick_up_resource(self.plate, pickup_distance_from_bottom=8) + await self.arm.drop_resource(self.site_b) + call = self.mock_backend.drop_at_location.call_args + # site_b(100,300,50) + default_child_loc(0,0,0), size_z=10 + # pickup_distance_from_bottom=8 → z = 50 + 8 = 58 + _assert_location(self, call, 160, 340, 58) + self.assertEqual(self.plate.parent.name, "site_b") + + async def test_pick_up_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0) + self.mock_backend.pick_up_at_location.assert_called_once_with( + location=location, resource_width=80.0, backend_params=None + ) + + async def test_drop_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0) + await self.arm.drop_at_location(location) + self.mock_backend.drop_at_location.assert_called_once_with( + location=location, resource_width=80.0, backend_params=None + ) + + async def test_move_to_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.move_to_location(location) + self.mock_backend.move_to_location.assert_called_once_with( + location=location, backend_params=None + ) + + async def test_open_gripper(self): + await self.arm.open_gripper(gripper_width=50.0) + self.mock_backend.open_gripper.assert_called_once_with(gripper_width=50.0, backend_params=None) + + async def test_halt(self): + await self.arm.halt() + self.mock_backend.halt.assert_called_once() + + async def test_park(self): + await self.arm.park() + self.mock_backend.park.assert_called_once() + + async def test_grip_axis_y(self): + """With grip_axis='y', resource_width should be the Y size.""" + arm_y = GripperArm(backend=self.mock_backend, reference_resource=self.deck, grip_axis="y") + await arm_y.pick_up_resource(self.plate, pickup_distance_from_bottom=8) + call = self.mock_backend.pick_up_at_location.call_args + # plate size_y=80 + self.assertAlmostEqual(call.kwargs["resource_width"], 80) + + +class TestOrientableArm(unittest.IsolatedAsyncioTestCase): + """Test OrientableArm coordinate computation with fictional resources.""" + + async def asyncSetUp(self): + self.mock_backend = MagicMock(spec=OrientableGripperArmBackend) + for method_name in [ + "pick_up_at_location", + "drop_at_location", + "move_to_location", + ]: + setattr(self.mock_backend, method_name, AsyncMock()) + + self.deck, self.site_a, self.site_b, self.plate = _make_deck_with_sites() + self.arm = OrientableArm(backend=self.mock_backend, reference_resource=self.deck) + + async def test_pick_up_front(self): + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_bottom=8, direction=GripDirection.FRONT + ) + call = self.mock_backend.pick_up_at_location.call_args + _assert_location(self, call, 165, 145, 58) + self.assertAlmostEqual(call.kwargs["direction"], 0.0) + # FRONT → X width = 120 + self.assertAlmostEqual(call.kwargs["resource_width"], 120) + + async def test_pick_up_right(self): + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_bottom=8, direction=GripDirection.RIGHT + ) + call = self.mock_backend.pick_up_at_location.call_args + self.assertAlmostEqual(call.kwargs["direction"], 90.0) + # RIGHT → Y width = 80 + self.assertAlmostEqual(call.kwargs["resource_width"], 80) + + async def test_drop_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0, direction=0.0) + await self.arm.drop_at_location(location, direction=180.0) + self.mock_backend.drop_at_location.assert_called_once_with( + location=location, direction=180.0, resource_width=80.0, backend_params=None + ) + + async def test_move_to_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.move_to_location(location, direction=90.0) + self.mock_backend.move_to_location.assert_called_once_with( + location=location, direction=90.0, backend_params=None + ) + + async def test_move_plate(self): + """Pick from site_a, drop at site_b.""" + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_bottom=8, direction=GripDirection.FRONT + ) + await self.arm.drop_resource(self.site_b, direction=GripDirection.FRONT) + drop_call = self.mock_backend.drop_at_location.call_args + _assert_location(self, drop_call, 160, 340, 58) + self.assertEqual(self.plate.parent.name, "site_b") diff --git a/pylabrobot/arms/articulated_arm.py b/pylabrobot/arms/articulated_arm.py new file mode 100644 index 00000000000..2336046e98e --- /dev/null +++ b/pylabrobot/arms/articulated_arm.py @@ -0,0 +1,10 @@ +import warnings + +warnings.warn( + "Importing from pylabrobot.arms.articulated_arm is deprecated. " + "Use pylabrobot.capabilities.arms.articulated_arm instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.capabilities.arms.articulated_arm import * # noqa: F401,F403,E402 diff --git a/pylabrobot/arms/backend.py b/pylabrobot/arms/backend.py index 8a6ebabd6f1..204aca34066 100644 --- a/pylabrobot/arms/backend.py +++ b/pylabrobot/arms/backend.py @@ -1,139 +1,10 @@ -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from typing import Dict, List, Optional, Union +import warnings -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.machines.backend import MachineBackend +warnings.warn( + "Importing from pylabrobot.arms.backend is deprecated. " + "Use pylabrobot.capabilities.arms.backend instead.", + DeprecationWarning, + stacklevel=2, +) - -@dataclass -class VerticalAccess: - """Access location from above (most common pattern for stacks and tube racks). - - This access pattern is used when approaching a location from above, such as - picking from a plate stack or tube rack on the deck. - - Args: - approach_height_mm: Height above the target position to move to before descending to grip (default: 100mm) - clearance_mm: Vertical distance to retract after gripping before lateral movement (default: 100mm) - gripper_offset_mm: Additional vertical offset added when holding a plate, accounts for gripper thickness (default: 10mm) - """ - - approach_height_mm: float = 100 - clearance_mm: float = 100 - gripper_offset_mm: float = 10 - - -@dataclass -class HorizontalAccess: - """Access location from the side (for hotel-style plate carriers). - - This access pattern is used when approaching a location horizontally, such as - accessing plates in a hotel-style storage system. - - Args: - approach_distance_mm: Horizontal distance in front of the target to stop before moving in to grip (default: 50mm) - clearance_mm: Horizontal distance to retract after gripping before lifting (default: 50mm) - lift_height_mm: Vertical distance to lift the plate after horizontal retract, before lateral movement (default: 100mm) - gripper_offset_mm: Additional vertical offset added when holding a plate, accounts for gripper thickness (default: 10mm) - """ - - approach_distance_mm: float = 50 - clearance_mm: float = 50 - lift_height_mm: float = 100 - gripper_offset_mm: float = 10 - - -AccessPattern = Union[VerticalAccess, HorizontalAccess] - - -class SCARABackend(MachineBackend, metaclass=ABCMeta): - """Backend for a robotic arm""" - - @abstractmethod - async def open_gripper(self, gripper_width: float) -> None: - """Open the arm's gripper.""" - - @abstractmethod - async def close_gripper(self, gripper_width: float) -> None: - """Close the arm's gripper.""" - - @abstractmethod - async def is_gripper_closed(self) -> bool: - """Check if the gripper is currently closed.""" - - @abstractmethod - async def halt(self) -> None: - """Stop any ongoing movement of the arm.""" - - @abstractmethod - async def home(self) -> None: - """Home the arm to its default position.""" - - @abstractmethod - async def move_to_safe(self) -> None: - """Move the arm to a predefined safe position.""" - - @abstractmethod - async def approach( - self, - position: Union[PreciseFlexCartesianCoords, Dict[int, float]], - access: Optional[AccessPattern] = None, - ) -> None: - """Move the arm to an approach position (offset from target). - - Args: - position: Target position (CartesianCoords or joint position dict) - access: Access pattern defining how to approach the target. Defaults to VerticalAccess() if not specified. - """ - - @abstractmethod - async def pick_up_resource( - self, - position: Union[PreciseFlexCartesianCoords, Dict[int, float]], - plate_width: float, - access: Optional[AccessPattern] = None, - ) -> None: - """Pick a plate from the specified position. - - Args: - position: Target position for pickup - access: Access pattern defining how to approach and retract. Defaults to VerticalAccess() if not specified. - """ - - @abstractmethod - async def drop_resource( - self, - position: Union[PreciseFlexCartesianCoords, Dict[int, float]], - access: Optional[AccessPattern] = None, - ) -> None: - """Place a plate at the specified position. - - Args: - position: Target position for placement - access: Access pattern defining how to approach and retract. Defaults to VerticalAccess() if not specified. - """ - - @abstractmethod - async def move_to(self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]]) -> None: - """Move the arm to a specified position in 3D space or in joint space.""" - - @abstractmethod - async def get_joint_position(self) -> Dict[int, float]: - """Get the current position of the arm in joint space.""" - - @abstractmethod - async def get_cartesian_position(self) -> PreciseFlexCartesianCoords: - """Get the current position of the arm in 3D space.""" - - @abstractmethod - async def freedrive_mode(self, free_axes: List[int]) -> None: - """Enter freedrive mode, allowing manual movement of the specified joints. - - Args: - free_axes: List of joint indices to free. - """ - - @abstractmethod - async def end_freedrive_mode(self) -> None: - """Exit freedrive mode.""" +from pylabrobot.capabilities.arms.backend import * # noqa: F401,F403,E402 diff --git a/pylabrobot/arms/orientable_arm.py b/pylabrobot/arms/orientable_arm.py new file mode 100644 index 00000000000..cc0596a6040 --- /dev/null +++ b/pylabrobot/arms/orientable_arm.py @@ -0,0 +1,10 @@ +import warnings + +warnings.warn( + "Importing from pylabrobot.arms.orientable_arm is deprecated. " + "Use pylabrobot.capabilities.arms.orientable_arm instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.capabilities.arms.orientable_arm import * # noqa: F401,F403,E402 diff --git a/pylabrobot/arms/precise_flex/pf_3400.py b/pylabrobot/arms/precise_flex/pf_3400.py deleted file mode 100644 index 152cc8a6b41..00000000000 --- a/pylabrobot/arms/precise_flex/pf_3400.py +++ /dev/null @@ -1,8 +0,0 @@ -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend - - -class PreciseFlex3400Backend(PreciseFlexBackend): - """Backend for the PreciseFlex 3400 robotic arm.""" - - def __init__(self, host: str, port: int = 10100, has_rail: bool = False, timeout=20) -> None: - super().__init__(host=host, port=port, has_rail=has_rail, timeout=timeout) diff --git a/pylabrobot/arms/precise_flex/pf_400.py b/pylabrobot/arms/precise_flex/pf_400.py deleted file mode 100644 index 524ab5020fe..00000000000 --- a/pylabrobot/arms/precise_flex/pf_400.py +++ /dev/null @@ -1,8 +0,0 @@ -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend - - -class PreciseFlex400Backend(PreciseFlexBackend): - """Backend for the PreciseFlex 400 robotic arm.""" - - def __init__(self, host: str, port: int = 10100, has_rail: bool = False, timeout=20) -> None: - super().__init__(host=host, port=port, has_rail=has_rail, timeout=timeout) diff --git a/pylabrobot/arms/precise_flex/precise_flex_backend.py b/pylabrobot/arms/precise_flex/precise_flex_backend.py deleted file mode 100644 index b7acab98566..00000000000 --- a/pylabrobot/arms/precise_flex/precise_flex_backend.py +++ /dev/null @@ -1,2361 +0,0 @@ -import asyncio -import warnings -from abc import ABC -from typing import Dict, List, Literal, Optional, Union - -from pylabrobot.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.resources import Coordinate, Rotation - - -class PreciseFlexError(Exception): - def __init__(self, replycode: int, message: str): - self.replycode = replycode - self.message = message - - # Map error codes to text and descriptions - error_info = ERROR_CODES - if replycode in error_info: - text = error_info[replycode]["text"] - description = error_info[replycode]["description"] - super().__init__(f"PreciseFlexError {replycode}: {text}. {description} - {message}") - else: - super().__init__(f"PreciseFlexError {replycode}: {message}") - - -class PreciseFlexBackend(SCARABackend, ABC): - """Backend for the PreciseFlex robotic arm - Default to using Cartesian coordinates, some methods in Brook's TCS don't work with Joint coordinates. - - Documentation and error codes available at https://www2.brooksautomation.com/#Root/Welcome.htm - """ - - def __init__( - self, - host: str, - port: int = 10100, - is_dual_gripper: bool = False, - has_rail: bool = False, - timeout=20, - ) -> None: - super().__init__() - self.io = Socket(human_readable_device_name="Precise Flex Arm", host=host, port=port) - self.profile_index: int = 1 - self.location_index: int = 1 - self.horizontal_compliance: bool = False - self.horizontal_compliance_torque: int = 0 - self.timeout = timeout - self._has_rail = has_rail - self._is_dual_gripper = is_dual_gripper - if is_dual_gripper: - warnings.warn( - "Dual gripper support is experimental and may not work as expected.", UserWarning - ) - - def _convert_to_cartesian_space( - self, position: tuple[float, float, float, float, float, float, Optional[ElbowOrientation]] - ) -> PreciseFlexCartesianCoords: - """Convert a tuple of cartesian coordinates to a CartesianCoords object.""" - if len(position) != 7: - raise ValueError( - "Position must be a tuple of 7 values (x, y, z, yaw, pitch, roll, orientation)." - ) - orientation = ElbowOrientation(position[6]) - return PreciseFlexCartesianCoords( - location=Coordinate(position[0], position[1], position[2]), - rotation=Rotation(position[5], position[4], position[3]), - orientation=orientation, - ) - - def _convert_to_cartesian_array( - self, position: PreciseFlexCartesianCoords - ) -> tuple[float, float, float, float, float, float, int]: - """Convert a CartesianCoords object to a list of cartesian coordinates.""" - orientation_int = self._convert_orientation_enum_to_int(position.orientation) - arr = ( - position.location.x, - position.location.y, - position.location.z, - position.rotation.yaw, - position.rotation.pitch, - position.rotation.roll, - orientation_int, - ) - return arr - - async def setup(self, skip_home: bool = False): - """Initialize the PreciseFlex backend.""" - 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() - - async def stop(self): - """Stop the PreciseFlex backend.""" - await self.detach() - await self.power_off_robot() - await self.exit() - await self.io.stop() - - async def set_speed(self, speed_percent: float): - """Set the speed percentage of the arm's movement (0-100).""" - await self.set_profile_speed(self.profile_index, speed_percent) - - async def get_speed(self) -> float: - """Get the current speed percentage of the arm's movement.""" - return await self.get_profile_speed(self.profile_index) - - async def open_gripper(self, gripper_width: float): - """Open the gripper to the specified width. If no width is specified, opens to the default open position.""" - await self._set_grip_open_pos(gripper_width) - await self.send_command("gripper 1") - - async def close_gripper(self, gripper_width: float): - """Close the gripper to the specified width. If no width is specified, closes to the default close position.""" - await self._set_grip_close_pos(gripper_width) - await self.send_command("gripper 2") - - async def halt(self): - """Stops the current robot immediately but leaves power on.""" - await self.send_command("halt") - - 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 move_to_safe(self) -> None: - """Moves the robot to Safe Position. - - Does not include checks for collision with 3rd party obstacles inside the work volume of the robot. - """ - await self.send_command("movetosafe") - - def _convert_orientation_int_to_enum(self, orientation_int: int) -> Optional[ElbowOrientation]: - if orientation_int == 1: - return ElbowOrientation.RIGHT - if orientation_int == 2: - return ElbowOrientation.LEFT - return None - - def _convert_orientation_enum_to_int(self, orientation: Optional[ElbowOrientation]) -> int: - if orientation == ElbowOrientation.LEFT: - return 2 - if orientation == ElbowOrientation.RIGHT: - return 1 - return 0 - - 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 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 power_on_robot(self): - """Power on the robot.""" - await self.set_power(True, self.timeout) - - async def power_off_robot(self): - """Power off the robot.""" - await self.set_power(False) - - async def approach( - self, - position: Union[PreciseFlexCartesianCoords, Dict[int, float]], - access: Optional[AccessPattern] = None, - ): - """Move the arm to an approach position (offset from target). - - Args: - position: Target position (CartesianCoords or Dict[int, float]) - access: Access pattern defining how to approach the target. Defaults to VerticalAccess() if not specified. - - Example: - # Simple vertical approach (default) - await backend.approach(position) - - # Horizontal hotel-style approach - await backend.approach( - position, - HorizontalAccess( - approach_distance_mm=50, - clearance_mm=50, - lift_height_mm=100 - ) - ) - """ - if access is None: - access = VerticalAccess() - - if isinstance(position, dict): - await self._approach_j(position, access) - elif isinstance(position, PreciseFlexCartesianCoords): - await self._approach_c(position, access) - else: - raise TypeError("Position must be of type Dict[int, float] or CartesianCoords.") - - async def pick_up_resource( - self, - position: Union[PreciseFlexCartesianCoords, Dict[int, float]], - plate_width: float, - access: Optional[AccessPattern] = None, - finger_speed_percent: float = 50.0, - grasp_force: float = 10.0, - ): - """Pick a plate from the specified position. - - Args: - position: Target position for pickup (CartesianCoords only, joint coords not supported) - plate_width: Gripper width in millimeters used when gripping the plate. - access: How to access the location (VerticalAccess or HorizontalAccess). Defaults to VerticalAccess() if not specified. - finger_speed_percent: Speed percentage for the gripper fingers (1-100) - grasp_force: Grasp force in Newtons - - Raises: - ValueError: If position is not CartesianCoords - - Example: - # Simple vertical pick (default) - await backend.pick_plate(position) - - # Vertical pick with custom clearance - await backend.pick_plate(position, VerticalAccess(clearance_mm=150)) - - # Horizontal hotel-style pick - await backend.pick_plate( - position, - HorizontalAccess( - approach_distance_mm=50, - clearance_mm=50, - lift_height_mm=100 - ) - ) - """ - if access is None: - access = VerticalAccess() - - await self.set_grasp_data( - plate_width=plate_width, - finger_speed_percent=finger_speed_percent, - grasp_force=grasp_force, - ) - - if isinstance(position, PreciseFlexCartesianCoords): - await self._pick_plate_c(cartesian_position=position, access=access) - elif isinstance(position, dict): - await self._pick_plate_j(position, access) - else: - raise TypeError("Position must be of type Dict[int, float] or CartesianCoords.") - - async def drop_resource( - self, - position: Union[PreciseFlexCartesianCoords, Dict[int, float]], - access: Optional[AccessPattern] = None, - ): - """Place a plate at the specified position. - - Args: - position: Target position for placement (CartesianCoords only, joint coords not supported) - access: How to access the location (VerticalAccess or HorizontalAccess). Defaults to VerticalAccess() if not specified. - - Raises: - ValueError: If position is not CartesianCoords - - Example: - # Simple vertical place (default) - await backend.place_plate(position) - - # Vertical place with custom clearance - await backend.place_plate(position, VerticalAccess(clearance_mm=150)) - - # Horizontal hotel-style place - await backend.place_plate( - position, - HorizontalAccess( - approach_distance_mm=50, - clearance_mm=50, - lift_height_mm=100 - ) - ) - """ - if access is None: - access = VerticalAccess() - - if not isinstance(position, PreciseFlexCartesianCoords): - raise TypeError("place_plate only supports CartesianCoords for PreciseFlex.") - await self._place_plate_c(cartesian_position=position, access=access) - - async def move_to(self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]]): - """Move the arm to a specified position in 3D space. - - Args: - position: Either CartesianCoords or a dict mapping PFAxis to float values. - When using a dict, any unspecified axes will be filled in from the current position. - """ - print(position, isinstance(position, dict)) - if isinstance(position, dict): - current = await self.get_joint_position() - joint_coords = {**current, **position} - await self.move_j(profile_index=self.profile_index, joint_coords=joint_coords) - elif isinstance(position, PreciseFlexCartesianCoords): - await self.move_c(profile_index=self.profile_index, cartesian_coords=position) - else: - raise TypeError("Position must be of type Dict[int, float] or CartesianCoords.") - - async def get_joint_position(self) -> Dict[int, float]: - """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 get_cartesian_position(self) -> PreciseFlexCartesianCoords: - """Get the current position of the arm in 3D space.""" - - await self.wait_for_eom() - - num_tries = 2 - for _ in range(num_tries): - data = await self.send_command("wherec") - parts = data.split() - if len(parts) == 7: - break - else: - raise PreciseFlexError(-1, "Unexpected response format from wherec command.") - - x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[0:6]) - config = int(parts[6]) - - # return (x, y, z, yaw, pitch, roll, config) - enum_thing = self._convert_orientation_int_to_enum(config) - - return self._convert_to_cartesian_space(position=(x, y, z, yaw, pitch, roll, enum_thing)) - - 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() # removes \r\n - 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: - # if error is reported, the data part generally contains the error message - raise PreciseFlexError(replycode, data) - - return data - - async def _approach_j(self, joint_position: Dict[int, float], access: AccessPattern): - """Move the arm to a position above the specified coordinates. - - The approach behavior depends on the access pattern: - - VerticalAccess: Approaches from above using approach_height_mm - - HorizontalAccess: Approaches from the side using approach_distance_mm - """ - await self.set_joint_angles(self.location_index, joint_position) - await self._set_grip_detail(access) - await self.move_to_stored_location_appro(self.location_index, self.profile_index) - - async def _pick_plate_j(self, joint_position: Dict[int, float], access: AccessPattern): - """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(access) - await self.pick_plate_from_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _place_plate_j(self, joint_position: Dict[int, float], access: AccessPattern): - """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(access) - await self.place_plate_to_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _approach_c( - self, - cartesian_position: PreciseFlexCartesianCoords, - access: AccessPattern, - ): - """Move the arm to a position above the specified coordinates. - - The approach behavior depends on the access pattern: - - VerticalAccess: Approaches from above using approach_height_mm - - HorizontalAccess: Approaches from the side using approach_distance_mm - """ - await self.set_location_xyz(self.location_index, cartesian_position) - await self._set_grip_detail(access) - orientation_int = self._convert_orientation_enum_to_int(cartesian_position.orientation) - await self.set_location_config(self.location_index, orientation_int) - await self.move_to_stored_location_appro(self.location_index, self.profile_index) - - async def _pick_plate_c( - self, - cartesian_position: PreciseFlexCartesianCoords, - access: AccessPattern, - ): - """Pick a plate from the specified position using Cartesian coordinates.""" - await self.set_location_xyz(self.location_index, cartesian_position) - await self._set_grip_detail(access) - orientation_int = self._convert_orientation_enum_to_int(cartesian_position.orientation) - orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° - await self.set_location_config(self.location_index, orientation_int) - await self.pick_plate_from_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _place_plate_c( - self, - cartesian_position: PreciseFlexCartesianCoords, - access: AccessPattern, - ): - """Place a plate at the specified position using Cartesian coordinates.""" - await self.set_location_xyz(self.location_index, cartesian_position) - await self._set_grip_detail(access) - orientation_int = self._convert_orientation_enum_to_int(cartesian_position.orientation) - orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° - await self.set_location_config(self.location_index, orientation_int) - await self.place_plate_to_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _set_grip_detail(self, access: AccessPattern): - """Configure station type for pick/place operations based on access pattern. - - Calls TCS set_station_type command to configure how the robot interprets - clearance values and performs approach/retract motions. - - Args: - access: Access pattern (VerticalAccess or HorizontalAccess) defining how to approach and retract from the location. - """ - if isinstance(access, VerticalAccess): - # Vertical access: access_type=1, z_clearance is vertical distance - await self.set_station_type( - station_id=self.location_index, - access_type=1, - location_type=0, - z_clearance=access.clearance_mm, - z_above=0, - z_grasp_offset=access.gripper_offset_mm, - ) - elif isinstance(access, HorizontalAccess): - # Horizontal access: access_type=0, z_clearance is horizontal distance - await self.set_station_type( - station_id=self.location_index, - access_type=0, - location_type=0, - z_clearance=access.clearance_mm, - z_above=access.lift_height_mm, - z_grasp_offset=access.gripper_offset_mm, - ) - else: - raise TypeError("Access pattern must be VerticalAccess or HorizontalAccess.") - - # region GENERAL COMMANDS - - async def get_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.") - - x_offset = float(parts[0]) - y_offset = float(parts[1]) - z_offset = float(parts[2]) - z_rotation = float(parts[3]) - - return (x_offset, y_offset, z_offset, z_rotation) - - 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 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") - - async def get_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 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}") - - ResponseMode = Literal["pc", "verbose"] - - async def get_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, PreciseFlexBackend.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 get_monitor_speed(self) -> int: - """Get the global system (monitor) speed. - - Returns: - Current monitor speed as a percentage (1-100) - """ - response = await self.send_command("mspeed") - return int(response) - - async def set_monitor_speed(self, speed_percent: int) -> None: - """Set the global system (monitor) speed. - - Args: - speed_percent: Speed percentage between 1 and 100, where 100 means full speed. - - Raises: - ValueError: If speed_percent is not between 1 and 100. - """ - if not (1 <= speed_percent <= 100): - raise ValueError("Speed percent must be between 1 and 100") - await self.send_command(f"mspeed {speed_percent}") - - 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") - - async def get_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_percent: int) -> None: - """Set the payload percent of maximum for the currently selected or attached robot. - - Args: - payload_percent: Payload percentage from 0 to 100 indicating the percent of the maximum payload the robot is carrying. - - Raises: - ValueError: If payload_percent 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_percent <= 100): - raise ValueError("Payload percent must be between 0 and 100") - await self.send_command(f"payload {payload_percent}") - - 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: - # 5 argument format - 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: - # 2 argument format - 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 get_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 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 get_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}") - - async def get_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}") - - async def get_system_state(self) -> int: - """Get the global system state code. - - Returns: - The global system state code. Please see documentation for DataID 234. - """ - response = await self.send_command("sysState") - return int(response) - - async def get_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") - # Remove "tool:" prefix if present - 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. - - The robot must be attached to set the tool. Setting the tool 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}") - - async def get_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") - - # region LOCATION COMMANDS - - async def get_location_angles(self, location_index: int) -> tuple[int, int, Dict[int, float]]: - """Get the angle values for the specified station index. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (type_code, station_index, angles_dict) - - Raises: - PreciseFlexError: If attempting to get angles from a Cartesian location. - """ - data = await self.send_command(f"locAngles {location_index}") - parts = data.split(" ") - - type_code = int(parts[0]) - if type_code != 1: - raise PreciseFlexError(-1, "Location is not of angles type.") - - station_index = int(parts[1]) - angles = self._parse_angles_response(parts[2:]) - - return (type_code, station_index, angles) - - async def set_joint_angles( - self, - location_index: int, - joint_position: Dict[int, float], - ) -> 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[PFAxis.RAIL]} " - f"{joint_position[PFAxis.BASE]} " - f"{joint_position[PFAxis.SHOULDER]} " - f"{joint_position[PFAxis.ELBOW]} " - f"{joint_position[PFAxis.WRIST]} " - f"{joint_position[PFAxis.GRIPPER]}" - ) - else: - await self.send_command( - f"locAngles {location_index} " - f"{joint_position[PFAxis.BASE]} " - f"{joint_position[PFAxis.SHOULDER]} " - f"{joint_position[PFAxis.ELBOW]} " - f"{joint_position[PFAxis.WRIST]} " - f"{joint_position[PFAxis.GRIPPER]}" - ) - - async def get_location_xyz( - self, location_index: int - ) -> tuple[int, int, float, float, float, float, float, float]: - """Get the Cartesian position values for the specified station index. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (type_code, station_index, X, Y, Z, yaw, pitch, roll) - - Raises: - PreciseFlexError: If attempting to get Cartesian position from an angles type location. - """ - data = await self.send_command(f"locXyz {location_index}") - parts = data.split(" ") - - type_code = int(parts[0]) - if type_code != 0: - raise PreciseFlexError(-1, "Location is not of Cartesian type.") - - if len(parts) != 8: - raise PreciseFlexError(-1, "Unexpected response format from locXyz command.") - - station_index = int(parts[1]) - x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[2:8]) - - return (type_code, station_index, x, y, z, yaw, pitch, roll) - - async def set_location_xyz( - self, - location_index: int, - cartesian_position: PreciseFlexCartesianCoords, - ) -> None: - """Set the Cartesian position values for the specified station index. - - Args: - location_index: The station index, from 1 to N_LOC. - cartesian_position: The Cartesian position to set. - """ - await self.send_command( - f"locXyz {location_index} " - f"{cartesian_position.location.x} " - f"{cartesian_position.location.y} " - f"{cartesian_position.location.z} " - f"{cartesian_position.rotation.yaw} " - f"{cartesian_position.rotation.pitch} " - f"{cartesian_position.rotation.roll}" - ) - - async def get_location_z_clearance(self, location_index: int) -> tuple[int, float, bool]: - """Get the ZClearance and ZWorld properties for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (station_index, z_clearance, z_world) - """ - data = await self.send_command(f"locZClearance {location_index}") - parts = data.split(" ") - - if len(parts) != 3: - raise PreciseFlexError(-1, "Unexpected response format from locZClearance command.") - - station_index = int(parts[0]) - z_clearance = float(parts[1]) - z_world = True if float(parts[2]) != 0 else False - - return (station_index, z_clearance, z_world) - - async def set_location_z_clearance( - self, location_index: int, z_clearance: float, z_world: Optional[bool] = None - ) -> None: - """Set the ZClearance and ZWorld properties for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - z_clearance: The new ZClearance property value. - z_world (float, optional): The new ZWorld property value. If omitted, only ZClearance is set. - """ - if z_world is None: - await self.send_command(f"locZClearance {location_index} {z_clearance}") - else: - z_world_int = 1 if z_world else 0 - await self.send_command(f"locZClearance {location_index} {z_clearance} {z_world_int}") - - async def get_location_config(self, location_index: int) -> tuple[int, int]: - """Get the Config property for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (station_index, config_value) - config_value is a bit mask where: - - 0 = None (no configuration specified) - - 0x01 = GPL_Righty (right shouldered configuration) - - 0x02 = GPL_Lefty (left shouldered configuration) - - 0x04 = GPL_Above (elbow above the wrist) - - 0x08 = GPL_Below (elbow below the wrist) - - 0x10 = GPL_Flip (wrist pitched up) - - 0x20 = GPL_NoFlip (wrist pitched down) - - 0x1000 = GPL_Single (restrict wrist axis to +/- 180 degrees) - Values can be combined using bitwise OR. - """ - data = await self.send_command(f"locConfig {location_index}") - parts = data.split(" ") - - if len(parts) != 2: - raise PreciseFlexError(-1, "Unexpected response format from locConfig command.") - - station_index = int(parts[0]) - config_value = int(parts[1]) - - return (station_index, config_value) - - async def set_location_config(self, location_index: int, config_value: int) -> None: - """Set the Config property for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - config_value: The new Config property value as a bit mask where: - - 0 = None (no configuration specified) - - 0x01 = GPL_Righty (right shouldered configuration) - - 0x02 = GPL_Lefty (left shouldered configuration) - - 0x04 = GPL_Above (elbow above the wrist) - - 0x08 = GPL_Below (elbow below the wrist) - - 0x10 = GPL_Flip (wrist pitched up) - - 0x20 = GPL_NoFlip (wrist pitched down) - - 0x1000 = GPL_Single (restrict wrist axis to +/- 180 degrees) - Values can be combined using bitwise OR. - - Raises: - ValueError: If config_value contains invalid bits or conflicting configurations. - """ - # Define valid bit masks - GPL_RIGHTY = 0x01 - GPL_LEFTY = 0x02 - GPL_ABOVE = 0x04 - GPL_BELOW = 0x08 - GPL_FLIP = 0x10 - GPL_NOFLIP = 0x20 - GPL_SINGLE = 0x1000 - - # All valid bits - ALL_VALID_BITS = ( - GPL_RIGHTY | GPL_LEFTY | GPL_ABOVE | GPL_BELOW | GPL_FLIP | GPL_NOFLIP | GPL_SINGLE - ) - - # Check for invalid bits - if config_value & ~ALL_VALID_BITS: - raise ValueError(f"Invalid config bits specified: 0x{config_value:X}") - - # Check for conflicting configurations - if (config_value & GPL_RIGHTY) and (config_value & GPL_LEFTY): - raise ValueError("Cannot specify both GPL_Righty and GPL_Lefty") - - if (config_value & GPL_ABOVE) and (config_value & GPL_BELOW): - raise ValueError("Cannot specify both GPL_Above and GPL_Below") - - if (config_value & GPL_FLIP) and (config_value & GPL_NOFLIP): - raise ValueError("Cannot specify both GPL_Flip and GPL_NoFlip") - - await self.send_command(f"locConfig {location_index} {config_value}") - - 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) -> Dict[int, float]: - """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 PFAxis 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}") - - # region PROFILE COMMANDS - - async def get_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_percent: float) -> None: - """Set the speed property of the specified profile. - - Args: - profile_index: The profile index to modify. - speed_percent: The new speed as a percentage. 100 = full speed. - Values > 100 may be accepted depending on system configuration. - """ - await self.send_command(f"Speed {profile_index} {speed_percent}") - - async def get_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_percent: float) -> None: - """Set the speed2 property of the specified profile. - - Args: - profile_index: The profile index to modify. - speed2_percent: The new speed2 as a percentage. 100 = full speed. - Used for Cartesian moves. Normally set to 0. - """ - await self.send_command(f"Speed2 {profile_index} {speed2_percent}") - - async def get_profile_accel(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, accel = response.split() - return float(accel) - - async def set_profile_accel(self, profile_index: int, accel_percent: float) -> None: - """Set the acceleration property of the specified profile. - - Args: - profile_index: The profile index to modify. - accel_percent: The new acceleration as a percentage. 100 = maximum acceleration. - Maximum value depends on system configuration. - """ - await self.send_command(f"Accel {profile_index} {accel_percent}") - - async def get_profile_accel_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, accel_ramp = response.split() - return float(accel_ramp) - - async def set_profile_accel_ramp(self, profile_index: int, accel_ramp_seconds: float) -> None: - """Set the acceleration ramp property of the specified profile. - - Args: - profile_index: The profile index to modify. - accel_ramp_seconds: The new acceleration ramp time in seconds. - """ - await self.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") - - async def get_profile_decel(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, decel = response.split() - return float(decel) - - async def set_profile_decel(self, profile_index: int, decel_percent: float) -> None: - """Set the deceleration property of the specified profile. - - Args: - profile_index: The profile index to modify. - decel_percent: The new deceleration as a percentage. 100 = maximum deceleration. - Maximum value depends on system configuration. - """ - await self.send_command(f"Decel {profile_index} {decel_percent}") - - async def get_profile_decel_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, decel_ramp = response.split() - return float(decel_ramp) - - async def set_profile_decel_ramp(self, profile_index: int, decel_ramp_seconds: float) -> None: - """Set the deceleration ramp property of the specified profile. - - Args: - profile_index: The profile index to modify. - decel_ramp_seconds: The new deceleration ramp time in seconds. - """ - await self.send_command(f"DecRamp {profile_index} {decel_ramp_seconds}") - - async def get_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 get_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 set_motion_profile_values( - self, - profile: int, - speed: float, - speed2: float, - acceleration: float, - deceleration: 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: Percentage of maximum speed. 100 = full speed. Values >100 may be accepted depending on system config. - speed2: Secondary speed setting, typically for Cartesian moves. Normally 0. Interpreted as a percentage. - acceleration: Percentage of maximum acceleration. 100 = full accel. - deceleration: Percentage of maximum deceleration. 100 = full decel. - 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): - raise ValueError("Speed must be > 0 (percent).") - - if not (0 <= speed2): - raise ValueError("Speed2 must be > 0 (percent).") - - if not (0 <= acceleration <= 100): - raise ValueError("Acceleration must be between 0 and 100 (percent).") - - if not (0 <= deceleration <= 100): - raise ValueError("Deceleration must be between 0 and 100 (percent).") - - 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} {speed2} {acceleration} {deceleration} {acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" - ) - - async def get_motion_profile_values( - self, profile: int - ) -> tuple[int, float, float, float, float, float, float, float, bool]: - """ - 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 tuple containing (profile, speed, speed2, acceleration, deceleration, acceleration_ramp, deceleration_ramp, in_range, straight) - - profile: Profile index - - speed: Percentage of maximum speed - - speed2: Secondary speed setting - - acceleration: Percentage of maximum acceleration - - deceleration: Percentage of maximum deceleration - - acceleration_ramp: Acceleration ramp time in seconds - - deceleration_ramp: Deceleration ramp time in seconds - - in_range: InRange value (-1 to 100) - - straight: True if straight-line path, False if joint-based path - """ - data = await self.send_command(f"Profile {profile}") - parts = data.split(" ") - if len(parts) != 9: - raise PreciseFlexError(-1, "Unexpected response format from device.") - - return ( - int(parts[0]), - float(parts[1]), - float(parts[2]), - float(parts[3]), - float(parts[4]), - float(parts[5]), - float(parts[6]), - float(parts[7]), - False if int(parts[8]) == 0 else True, - ) - - # region MOTION COMMANDS - 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 move_extra_axis( - self, axis1_position: float, axis2_position: Optional[float] = None - ) -> None: - """Post a move for one or two extra axes during the next Cartesian motion. - - Does not cause the robot to move at this time. Only some kinematic modules support extra axes. - - Args: - axis1_position: The destination position for the 1st extra axis. - axis2_position (float, optional): The destination position for the 2nd extra axis, if any. - - Note: - Requires that the robot be attached. - """ - if axis2_position is None: - await self.send_command(f"moveExtraAxis {axis1_position}") - else: - await self.send_command(f"moveExtraAxis {axis1_position} {axis2_position}") - - async def move_one_axis( - self, axis_number: int, destination_position: float, profile_index: int - ) -> None: - """Move a single axis to the specified position using the specified profile. - - Args: - axis_number: The number of the axis to move. - destination_position: The destination position for this axis. - profile_index: The index of the profile to use during this motion. - - Note: - Requires that the robot be attached. - """ - await self.send_command(f"moveOneAxis {axis_number} {destination_position} {profile_index}") - - async def move_c( - self, - profile_index: int, - cartesian_coords: PreciseFlexCartesianCoords, - ) -> None: - """Move the robot to the Cartesian location specified by the arguments. - - Args: - profile_index: The profile index to use for this motion. - cartesian_coords: The Cartesian coordinates to which the robot should move. - - Note: - Requires that the robot be attached. - """ - - cmd = ( - f"moveC {profile_index} " - f"{cartesian_coords.location.x} " - f"{cartesian_coords.location.y} " - f"{cartesian_coords.location.z} " - f"{cartesian_coords.rotation.yaw} " - f"{cartesian_coords.rotation.pitch} " - f"{cartesian_coords.rotation.roll} " - ) - - if cartesian_coords.orientation is not None: - config_int = self._convert_orientation_enum_to_int(cartesian_coords.orientation) - config_int |= 0x1000 # GPL_Single: restrict wrist to ±180° - cmd += f"{config_int}" - - await self.send_command(cmd) - - async def move_j(self, profile_index: int, joint_coords: Dict[int, float]) -> None: - """Move the robot using joint coordinates, handling rail configuration.""" - if self._has_rail: - angles_str = ( - f"{joint_coords[PFAxis.BASE]} " - f"{joint_coords[PFAxis.SHOULDER]} " - f"{joint_coords[PFAxis.ELBOW]} " - f"{joint_coords[PFAxis.WRIST]} " - f"{joint_coords[PFAxis.GRIPPER]} " - f"{joint_coords[PFAxis.RAIL]} " - ) - else: - angles_str = ( - f"{joint_coords[PFAxis.BASE]} " - f"{joint_coords[PFAxis.SHOULDER]} " - f"{joint_coords[PFAxis.ELBOW]} " - f"{joint_coords[PFAxis.WRIST]} " - f"{joint_coords[PFAxis.GRIPPER]}" - ) - await self.send_command(f"moveJ {profile_index} {angles_str}") - - 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 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") - - async def wait_for_eom(self) -> None: - """Wait for the robot to reach the end of the current motion. - - Waits for the robot to reach the end of the current motion or until it is stopped by - some other means. Does not reply until the robot has stopped. - """ - await self.send_command("waitForEom") - await asyncio.sleep(0.2) # Small delay to ensure command is fully processed - - 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") - - # region PAROBOT COMMANDS - - 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 get_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_percent, grasp_force) - """ - data = await self.send_command("GraspData") - parts = data.split() - - if len(parts) != 3: - raise PreciseFlexError(-1, "Unexpected response format from GraspData command.") - - plate_width = float(parts[0]) - finger_speed = float(parts[1]) - grasp_force = float(parts[2]) - - return (plate_width, finger_speed, grasp_force) - - async def set_grasp_data( - self, plate_width: float, finger_speed_percent: 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_mm: The plate width in mm. - finger_speed_percent: The finger speed during grasp where 100 means 100%. - 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. - """ - await self.send_command(f"GraspData {plate_width} {finger_speed_percent} {grasp_force}") - - async def _get_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 _get_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 move_rail( - self, station_id: Optional[int] = None, mode: int = 0, rail_destination: Optional[float] = None - ) -> None: - """Moves the optional linear rail. - - The rail may be moved immediately or simultaneously with the next pick or place motion. - The location may be associated with the station or specified explicitly. - - Args: - station_id: The destination station ID. Only used if rail_destination is omitted. - mode: Mode of operation. - 0 or omitted = cancel any pending MoveRail - 1 = Move rail immediately - 2 = Move rail during next pick or place - rail_destination (float, optional): If specified, use this value as the rail destination - rather than the station location. - """ - if rail_destination is not None: - await self.send_command(f"MoveRail {station_id or ''} {mode} {rail_destination}") - elif station_id is not None: - await self.send_command(f"MoveRail {station_id} {mode}") - else: - await self.send_command(f"MoveRail {mode}") - - async def get_pallet_index(self, station_id: int) -> tuple[int, int, int, int]: - """Get the current pallet index values for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, pallet_index_x, pallet_index_y, pallet_index_z) - """ - data = await self.send_command(f"PalletIndex {station_id}") - parts = data.split() - - if len(parts) != 4: - raise PreciseFlexError(-1, "Unexpected response format from PalletIndex command.") - - station_id = int(parts[0]) - pallet_index_x = int(parts[1]) - pallet_index_y = int(parts[2]) - pallet_index_z = int(parts[3]) - - return (station_id, pallet_index_x, pallet_index_y, pallet_index_z) - - async def set_pallet_index( - self, station_id: int, pallet_index_x: int = 0, pallet_index_y: int = 0, pallet_index_z: int = 0 - ) -> None: - """Set the pallet index value from 1 to n of the station used by subsequent pick or place. - - If an index argument is 0 or omitted, the corresponding index is not changed. - Negative values generate an error. - - Args: - station_id: Station ID, from 1 to N_LOC. - pallet_index_x: Pallet index X. If 0 or omitted, X index is not changed. - pallet_index_y: Pallet index Y. If 0 or omitted, Y index is not changed. - pallet_index_z: Pallet index Z. If 0 or omitted, Z index is not changed. - - Raises: - ValueError: If any index value is negative. - """ - if pallet_index_x < 0: - raise ValueError("Pallet index X cannot be negative") - if pallet_index_y < 0: - raise ValueError("Pallet index Y cannot be negative") - if pallet_index_z < 0: - raise ValueError("Pallet index Z cannot be negative") - - await self.send_command( - f"PalletIndex {station_id} {pallet_index_x} {pallet_index_y} {pallet_index_z}" - ) - - async def get_pallet_origin( - self, station_id: int - ) -> tuple[int, float, float, float, float, float, float, int]: - """Get the current pallet origin data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, x, y, z, yaw, pitch, roll, config) - """ - data = await self.send_command(f"PalletOrigin {station_id}") - parts = data.split() - - if len(parts) != 8: - raise PreciseFlexError(-1, "Unexpected response format from PalletOrigin command.") - - station_id = int(parts[0]) - x = float(parts[1]) - y = float(parts[2]) - z = float(parts[3]) - yaw = float(parts[4]) - pitch = float(parts[5]) - roll = float(parts[6]) - config = int(parts[7]) - - return (station_id, x, y, z, yaw, pitch, roll, config) - - async def set_pallet_origin( - self, - station_id: int, - cartesian_coords: PreciseFlexCartesianCoords, - ) -> None: - """Define the origin of a pallet reference frame. - - Specifies the world location and orientation of the (1,1,1) pallet position. - Must be followed by a PalletX command. - - The orientation and configuration specified here determines the world orientation - of the robot during all pick or place operations using this pallet. - - Args: - station_id: Station ID, from 1 to N_LOC. - cartesian_coords: The Cartesian coordinates defining the pallet origin. - """ - - cmd = ( - f"PalletOrigin {station_id} " - f"{cartesian_coords.location.x} " - f"{cartesian_coords.location.y} " - f"{cartesian_coords.location.z} " - f"{cartesian_coords.rotation.yaw} " - f"{cartesian_coords.rotation.pitch} " - f"{cartesian_coords.rotation.roll} " - ) - - if cartesian_coords.orientation is not None: - config_int = self._convert_orientation_enum_to_int(cartesian_coords.orientation) - cmd += f"{config_int}" - - await self.send_command(cmd) - - async def get_pallet_x(self, station_id: int) -> tuple[int, int, float, float, float]: - """Get the current pallet X data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, x_position_count, world_x, world_y, world_z) - """ - data = await self.send_command(f"PalletX {station_id}") - parts = data.split() - - if len(parts) != 5: - raise PreciseFlexError(-1, "Unexpected response format from PalletX command.") - - station_id = int(parts[0]) - x_position_count = int(parts[1]) - world_x = float(parts[2]) - world_y = float(parts[3]) - world_z = float(parts[4]) - - return (station_id, x_position_count, world_x, world_y, world_z) - - async def set_pallet_x( - self, station_id: int, x_position_count: int, world_x: float, world_y: float, world_z: float - ) -> None: - """Define the last point on the pallet X axis. - - Specifies the world location of the (n,1,1) pallet position, where n is the x_position_count value. - Must follow a PalletOrigin command. - - Args: - station_id: Station ID, from 1 to N_LOC. - x_position_count: X position count. - world_x: World location X coordinate. - world_y: World location Y coordinate. - world_z: World location Z coordinate. - """ - await self.send_command( - f"PalletX {station_id} {x_position_count} {world_x} {world_y} {world_z}" - ) - - async def get_pallet_y(self, station_id: int) -> tuple[int, int, float, float, float]: - """Get the current pallet Y data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, y_position_count, world_x, world_y, world_z) - """ - data = await self.send_command(f"PalletY {station_id}") - parts = data.split() - - if len(parts) != 5: - raise PreciseFlexError(-1, "Unexpected response format from PalletY command.") - - station_id = int(parts[0]) - y_position_count = int(parts[1]) - world_x = float(parts[2]) - world_y = float(parts[3]) - world_z = float(parts[4]) - - return (station_id, y_position_count, world_x, world_y, world_z) - - async def set_pallet_y( - self, station_id: int, y_position_count: int, world_x: float, world_y: float, world_z: float - ) -> None: - """Define the last point on the pallet Y axis. - - Specifies the world location of the (1,n,1) pallet position, where n is the y_position_count value. - If this command is executed, a 2 or 3-dimensional pallet is assumed. - Must follow a PalletX command. - - Args: - station_id: Station ID, from 1 to N_LOC. - y_position_count: Y position count. - world_x: World location X coordinate. - world_y: World location Y coordinate. - world_z: World location Z coordinate. - """ - await self.send_command( - f"PalletY {station_id} {y_position_count} {world_x} {world_y} {world_z}" - ) - - async def get_pallet_z(self, station_id: int) -> tuple[int, int, float, float, float]: - """Get the current pallet Z data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, z_position_count, world_x, world_y, world_z) - """ - data = await self.send_command(f"PalletZ {station_id}") - parts = data.split() - - if len(parts) != 5: - raise PreciseFlexError(-1, "Unexpected response format from PalletZ command.") - - station_id = int(parts[0]) - z_position_count = int(parts[1]) - world_x = float(parts[2]) - world_y = float(parts[3]) - world_z = float(parts[4]) - - return (station_id, z_position_count, world_x, world_y, world_z) - - async def set_pallet_z( - self, station_id: int, z_position_count: int, world_x: float, world_y: float, world_z: float - ) -> None: - """Define the last point on the pallet Z axis. - - Specifies the world location of the (1,1,n) pallet position, where n is the z_position_count value. - If this command is executed, a 3-dimensional pallet is assumed. - Must follow a PalletX and PalletY command. - - Args: - station_id: Station ID, from 1 to N_LOC. - z_position_count: Z position count. - world_x: World location X coordinate. - world_y: World location Y coordinate. - world_z: World location Z coordinate. - """ - await self.send_command( - f"PalletZ {station_id} {z_position_count} {world_x} {world_y} {world_z}" - ) - - async def pick_plate_station( - self, - station_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, - ) -> bool: - """Moves to a predefined position or pallet location and picks up plate. - - If the arm must change configuration, it automatically goes through the Park position. - At the conclusion of this routine, the arm is left gripping the plate and stopped at the nest approach position. - Use Teach function to teach station pick point. - - Args: - station_id: Station ID, from 1 to Max. - horizontal_compliance: If True, enable horizontal compliance while closing the gripper to allow centering around the plate. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - - Returns: - bool: True if the plate was successfully grasped or force control was not used. - False if the force-controlled gripper detected no plate present. - """ - horizontal_compliance_int = 1 if horizontal_compliance else 0 - ret_code = await self.send_command( - f"PickPlate {station_id} {horizontal_compliance_int} {horizontal_compliance_torque}" - ) - return ret_code != "0" - - async def place_plate_station( - self, - station_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, - ) -> None: - """Moves to a predefined position or pallet location and places a plate. - - If the arm must change configuration, it automatically goes through the Park position. - At the conclusion of this routine, the arm is left gripping the plate and stopped at the nest approach position. - Use Teach function to teach station place point. - - Args: - station_id: Station ID, from 1 to Max. - horizontal_compliance: If True, enable horizontal compliance during the move to place the plate, to allow centering in the fixture. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - """ - horizontal_compliance_int = 1 if horizontal_compliance else 0 - await self.send_command( - f"PlacePlate {station_id} {horizontal_compliance_int} {horizontal_compliance_torque}" - ) - - async def get_rail_position(self, station_id: int) -> float: - """Get the position of the optional rail axis that is associated with a station. - - Args: - station_id: Station ID, from 1 to Max. - - Returns: - float: The current rail position for the specified station. - """ - data = await self.send_command(f"Rail {station_id}") - return float(data) - - async def set_rail_position(self, station_id: int, rail_position: float) -> None: - """Set the position of the optional rail axis that is associated with a station. - - The station rail data is loaded and saved by the LoadFile and StoreFile commands. - - Args: - station_id: Station ID, from 1 to Max. - rail_position: The new rail position. - """ - await self.send_command(f"Rail {station_id} {rail_position}") - - async def teach_plate_station(self, station_id: int, z_clearance: float = 50.0) -> None: - """Sets the plate location to the current robot position and configuration. - - The location is saved as Cartesian coordinates. Z clearance must be high enough to withdraw the gripper. - If this station is a pallet, the pallet indices must be set to 1, 1, 1. The pallet frame is not changed, - only the location relative to the pallet. - - Args: - station_id: Station ID, from 1 to Max. - z_clearance: The Z Clearance value. If omitted, a value of 50 is used. If specified and non-zero, this value is used. - """ - await self.send_command(f"TeachPlate {station_id} {z_clearance}") - - async def get_station_type(self, station_id: int) -> tuple[int, int, int, float, float, float]: - """Get the station configuration for the specified station ID. - - Args: - station_id: Station ID, from 1 to Max. - - Returns: - A tuple containing (station_id, access_type, location_type, z_clearance, z_above, z_grasp_offset) - - station_id: The station ID - - access_type: 0 = horizontal, 1 = vertical - - location_type: 0 = normal single, 1 = pallet (1D, 2D, 3D) - - z_clearance: ZClearance value in mm - - z_above: ZAbove value in mm - - z_grasp_offset: ZGrasp offset - """ - data = await self.send_command(f"StationType {station_id}") - parts = data.split() - - if len(parts) != 6: - raise PreciseFlexError(-1, "Unexpected response format from StationType command.") - - station_id = int(parts[0]) - access_type = int(parts[1]) - location_type = int(parts[2]) - z_clearance = float(parts[3]) - z_above = float(parts[4]) - z_grasp_offset = float(parts[5]) - - return (station_id, access_type, location_type, z_clearance, z_above, z_grasp_offset) - - async def set_station_type( - self, - station_id: int, - access_type: int, - location_type: int, - z_clearance: float, - z_above: float, - z_grasp_offset: float, - ) -> None: - """Set the station configuration for the specified station ID. - - Args: - station_id: Station ID, from 1 to Max. - access_type: The station access type. - 0 = horizontal (for "hotel" carriers accessed by horizontal move) - 1 = vertical (for stacks or tube racks accessed with vertical motion) - location_type: The location type. - 0 = normal single location - 1 = pallet (1D, 2D, or 3D regular arrays requiring column, row, and layer index) - z_clearance: ZClearance value in mm. The horizontal or vertical distance - from the final location used when approaching or departing from a station. - z_above: ZAbove value in mm. The vertical offset used with horizontal - access when approaching or departing from the location. - z_grasp_offset: ZGrasp offset. Added to ZClearance when an object is - being held to compensate for the part in the gripper. - - Raises: - ValueError: If access_type or location_type are not valid values. - """ - if access_type not in [0, 1]: - raise ValueError("Access type must be 0 (horizontal) or 1 (vertical)") - - if location_type not in [0, 1]: - raise ValueError("Location type must be 0 (normal single) or 1 (pallet)") - - await self.send_command( - f"StationType {station_id} {access_type} {location_type} {z_clearance} {z_above} {z_grasp_offset}" - ) - - # region SSGRIP COMMANDS - - async def home_all_if_no_plate(self) -> int: - """Tests if the gripper is holding a plate. If not, enable robot power and home all robots. - - Returns: - -1 if no plate detected and the command succeeded, 0 if a plate was detected. - """ - response = await self.send_command("HomeAll_IfNoPlate") - return int(response) - - async def _grasp_plate( - self, plate_width_mm: float, finger_speed_percent: int, grasp_force: float - ) -> int: - """Grasps a plate with limited force. - - Low level method. Use `pick_plate` instead for typical pick-and-place operations. - - A plate can be grasped by opening or closing the gripper. The actual commanded gripper - width generated by this function is a few mm smaller (or larger) than plate_width_mm - to permit the servos PID loop to generate the gripping force. - - Args: - plate_width_mm: Plate width in mm. Should be accurate to within about 1 mm. - finger_speed_percent: Percent speed to close fingers. 1 to 100. - grasp_force: Maximum gripper squeeze force in Newtons. - A positive value indicates the fingers must close to grasp. - A negative value indicates the fingers must open to grasp. - - Returns: - -1 if the plate has been grasped, 0 if the final gripping force indicates no plate. - - Raises: - ValueError: If finger_speed_percent is not between 1 and 100. - """ - if not (1 <= finger_speed_percent <= 100): - raise ValueError("Finger speed percent must be between 1 and 100") - - response = await self.send_command( - f"GraspPlate {plate_width_mm} {finger_speed_percent} {grasp_force}" - ) - return int(response) - - async def _release_plate( - self, open_width_mm: float, finger_speed_percent: int, in_range: float = 0.0 - ) -> None: - """Releases the plate after a GraspPlate command. - - Low level method. Use `place_plate` instead for typical pick-and-place operations. - - Opens (or closes) the gripper to the specified width and cancels the force limit - once the plate is released to avoid applying an excessive force to the plate. - - Args: - open_width_mm: Open width in mm. - finger_speed_percent: Percent speed to open fingers. 1 to 100. - in_range: Optional. The standard InRange profile property for the gripper open move. - If omitted, a zero value is assumed. - - Raises: - ValueError: If finger_speed_percent is not between 1 and 100. - """ - if not (1 <= finger_speed_percent <= 100): - raise ValueError("Finger speed percent must be between 1 and 100") - - await self.send_command(f"ReleasePlate {open_width_mm} {finger_speed_percent} {in_range}") - - 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) - - async def set_active_gripper( - self, gripper_id: int, spin_mode: int = 0, profile_index: Optional[int] = None - ) -> None: - """(Dual Gripper Only) Sets the currently active gripper and modifies the tool reference frame. - - Args: - gripper_id: Gripper ID, either 1 or 2. Determines which gripper is set to active. - spin_mode: Optional spin mode. - 0 or omitted = do not rotate the gripper 180deg immediately. - 1 = Rotate gripper 180deg immediately. - profile_index: Profile Index to use for spin motion. - - Raises: - ValueError: If gripper_id is not 1 or 2, or if spin_mode is not 0 or 1. - """ - if gripper_id not in [1, 2]: - raise ValueError("Gripper ID must be 1 or 2") - - if spin_mode not in [0, 1]: - raise ValueError("Spin mode must be 0 or 1") - - if profile_index is not None: - await self.send_command(f"SetActiveGripper {gripper_id} {spin_mode} {profile_index}") - else: - await self.send_command(f"SetActiveGripper {gripper_id} {spin_mode}") - - async def get_active_gripper(self) -> int: - """(Dual Gripper Only) Returns the currently active gripper. - - Returns: - 1 if Gripper A is active, 2 if Gripper B is active. - """ - if not self._is_dual_gripper: - raise ValueError("GetActiveGripper command is only valid for dual gripper robots.") - response = await self.send_command("GetActiveGripper") - return int(response) - - async def freedrive_mode(self, free_axes: List[int]) -> 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. - """ - for axis in free_axes: - await self.send_command(f"freemode {axis}") - - async def end_freedrive_mode(self) -> None: - """Exit freedrive mode for all axes.""" - await self.send_command("freemode -1") - - async def pick_plate_from_stored_position( - self, - position_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, - ): - """Pick an item at the specified position ID. - - Args: - position_id: The ID of the position where the plate should be picked. - horizontal_compliance: enable horizontal compliance while closing the gripper to allow centering around the plate. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - """ - horizontal_compliance_int = 1 if horizontal_compliance else 0 - ret_code = await self.send_command( - f"pickplate {position_id} {horizontal_compliance_int} {horizontal_compliance_torque}" - ) - if ret_code == "0": - raise PreciseFlexError(-1, "the force-controlled gripper detected no plate present.") - - async def place_plate_to_stored_position( - self, - position_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, - ): - """Place an item at the specified position ID. - - Args: - position_id: The ID of the position where the plate should be placed. - horizontal_compliance: enable horizontal compliance during the move to place the plate, to allow centering in the fixture. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - """ - horizontal_compliance_int = 1 if horizontal_compliance else 0 - await self.send_command( - f"placeplate {position_id} {horizontal_compliance_int} {horizontal_compliance_torque}" - ) - - async def teach_position(self, position_id: int, z_clearance: float = 50.0): - """Sets the plate location to the current robot position and configuration. The location is saved as Cartesian coordinates. - - Args: - position_id: The ID of the position to be taught. - z_clearance: Optional. The Z Clearance value. If omitted, a value of 50 is used. Z clearance must be high enough to withdraw the gripper. - """ - await self.send_command(f"teachplate {position_id} {z_clearance}") - - 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.") - - x = float(parts[0]) - y = float(parts[1]) - z = float(parts[2]) - yaw = float(parts[3]) - pitch = float(parts[4]) - roll = float(parts[5]) - - return (x, y, z, yaw, pitch, roll) - - def _parse_angles_response(self, parts: List[str]) -> Dict[int, float]: - """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 { - PFAxis.RAIL: float(parts[5]) if len(parts) > 5 else 0.0, - PFAxis.BASE: float(parts[0]), - PFAxis.SHOULDER: float(parts[1]), - PFAxis.ELBOW: float(parts[2]), - PFAxis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, - PFAxis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, - } - - return { - PFAxis.RAIL: 0.0, - PFAxis.BASE: float(parts[0]), - PFAxis.SHOULDER: float(parts[1]), - PFAxis.ELBOW: float(parts[2]) if len(parts) > 2 else 0.0, - PFAxis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, - PFAxis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, - } diff --git a/pylabrobot/arms/standard.py b/pylabrobot/arms/standard.py index a3fb8c4f3a4..2f653072394 100644 --- a/pylabrobot/arms/standard.py +++ b/pylabrobot/arms/standard.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass +import warnings -from pylabrobot.resources import Coordinate, Rotation +warnings.warn( + "Importing from pylabrobot.arms.standard is deprecated. " + "Use pylabrobot.capabilities.arms.standard instead.", + DeprecationWarning, + stacklevel=2, +) - -@dataclass -class CartesianCoords: - location: Coordinate - rotation: Rotation +from pylabrobot.capabilities.arms.standard import * # noqa: F401,F403,E402 diff --git a/pylabrobot/azenta/__init__.py b/pylabrobot/azenta/__init__.py new file mode 100644 index 00000000000..b00149e5035 --- /dev/null +++ b/pylabrobot/azenta/__init__.py @@ -0,0 +1,2 @@ +from .a4s import A4S, A4SDriver, A4SSealerBackend, A4SStatus, A4STemperatureBackend +from .xpeel import XPeel, XPeelDriver, XPeelPeelerBackend diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py new file mode 100644 index 00000000000..20cdeca89c6 --- /dev/null +++ b/pylabrobot/azenta/a4s.py @@ -0,0 +1,334 @@ +import asyncio +import dataclasses +import enum +import logging +import time +from typing import Optional, Set + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.sealing import Sealer, SealerBackend +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureController, + TemperatureControllerBackend, +) +from pylabrobot.device import Device, Driver +from pylabrobot.io.serial import Serial +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder + +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 A4SDriver(Driver): + """Serial driver for the Azenta a4S thermal sealer. + + Owns I/O, connection lifecycle, and device-level operations (status polling, + system reset, heater on/off, timing). + + 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, backend_params: Optional[BackendParams] = None): + await super().setup(backend_params=backend_params) # type: ignore[safe-super] + 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 super().stop() # type: ignore[safe-super] + 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 + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port, "timeout": self.timeout} + + +class A4SSealerBackend(SealerBackend): + """Translates SealerBackend operations into A4S driver commands.""" + + def __init__(self, driver: A4SDriver): + self.driver = driver + + async def seal(self, temperature: int, duration: float): + logger.info("[A4S %s] sealing at %d C for %.1fs", self.driver.port, temperature, duration) + await self.driver.send_command(f"*00DH={round(temperature):04d}zz!") + await self._wait_for_temperature(temperature, timeout=300) + await self.driver.set_time(duration) + await self.driver.send_command("*00GS=zz!") + await self.driver.wait_for_status({A4SStatus.SystemStatus.single_cycle}) + return await self.driver.wait_for_status( + {A4SStatus.SystemStatus.idle, A4SStatus.SystemStatus.finish} + ) + + async def open(self): + logger.info("[A4S %s] open shuttle", self.driver.port) + await self.driver.send_command("*00MO=zz!") + return await self.driver.wait_for_shuttle_open_sensor(True) + + async def close(self): + logger.info("[A4S %s] close shuttle", self.driver.port) + await self.driver.send_command("*00MC=zz!") + return await self.driver.wait_for_shuttle_open_sensor(False) + + async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): + start = time.time() + while True: + status = await self.driver.request_status() + if abs(status.current_temperature - degrees) < tolerance: + break + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for temperature") + await asyncio.sleep(0.1) + + +class A4STemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend operations into A4S driver commands.""" + + def __init__(self, driver: A4SDriver): + self.driver = driver + + @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.driver.port, temperature) + command = f"*00DH={round(temperature):04d}zz!" + await self.driver.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.driver.request_status() + temp = status.current_temperature + logger.info("[A4S %s] read temperature: actual=%.1f C", self.driver.port, temp) + return temp + + async def deactivate(self): + logger.info("[A4S %s] deactivate heater", self.driver.port) + await self.driver.set_heater(on=False) + + +class A4S(PlateHolder, Device): + """Azenta a4S automated thermal sealer. + + 222 x 500 x 276 mm + """ + + def __init__( + self, + name: str, + port: str, + timeout: int = 20, + size_x: float = 222, + size_y: float = 500, + size_z: float = 276, + child_location: Coordinate = Coordinate(0, 0, 0), # TODO + pedestal_size_z: float = 0, # TODO + category: str = "sealer", + model: Optional[str] = None, + ): + raise NotImplementedError("A4S is missing resource definition.") + driver = A4SDriver(port=port, timeout=timeout) + 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=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.driver: A4SDriver = driver + self.sealer = Sealer(backend=A4SSealerBackend(driver)) + self.tc = TemperatureController(backend=A4STemperatureBackend(driver)) + self._capabilities = [self.tc, self.sealer] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py new file mode 100644 index 00000000000..8d61da1cc70 --- /dev/null +++ b/pylabrobot/azenta/xpeel.py @@ -0,0 +1,300 @@ +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.capabilities.capability import BackendParams +from pylabrobot.capabilities.peeling import Peeler, PeelerBackend +from pylabrobot.device import Device, Driver +from pylabrobot.io.serial import Serial +from pylabrobot.serializer import SerializableMixin + + +class XPeelDriver(Driver): + """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, backend_params: Optional[BackendParams] = None): + 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) + + +class XPeelPeelerBackend(PeelerBackend): + """Translates PeelerBackend interface into XPeel driver commands. + + Protocol encoding for peel and restart operations lives here. + """ + + @dataclass + class PeelParams(BackendParams): + """XPeel-specific parameters for the peel (de-seal) operation. + + 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. + """ + + begin_location: Literal[-2, 0, 2, 4] = 0 + fast: bool = False + adhere_time: float = 2.5 + + def __init__(self, driver: XPeelDriver): + self.driver = driver + + async def peel( + self, + backend_params: Optional[SerializableMixin] = None, + ): + """Run an automated de-seal cycle.""" + if not isinstance(backend_params, self.PeelParams): + backend_params = XPeelPeelerBackend.PeelParams() + + adhere_time = backend_params.adhere_time + begin_location = backend_params.begin_location + fast = backend_params.fast + + 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.driver.send_command(cmd, expect_ack=True, wait_for_ready=True) + + async def restart(self, backend_params: Optional[SerializableMixin] = None): + """Request restart with full homing sequence.""" + return await self.driver.send_command("*restart", expect_ack=True, wait_for_ready=True) + + +class XPeel(Device): + """Azenta XPeel automated plate seal remover.""" + + def __init__(self, name: str, port: str, timeout: Optional[float] = None): + driver = XPeelDriver(port=port, timeout=timeout) + super().__init__(driver=driver) + self.driver: XPeelDriver = driver + self.peeler = Peeler(backend=XPeelPeelerBackend(driver)) + self._capabilities = [self.peeler] 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/barcode_scanners/keyence/__init__.py b/pylabrobot/barcode_scanners/keyence/__init__.py deleted file mode 100644 index db64521ca5c..00000000000 --- a/pylabrobot/barcode_scanners/keyence/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .keyence_backend import KeyenceBarcodeScannerBackend diff --git a/pylabrobot/bmg_labtech/__init__.py b/pylabrobot/bmg_labtech/__init__.py new file mode 100644 index 00000000000..3a5a13983a0 --- /dev/null +++ b/pylabrobot/bmg_labtech/__init__.py @@ -0,0 +1,8 @@ +from .clariostar import ( + CLARIOstar, + CLARIOstarAbsorbanceBackend, + CLARIOstarAbsorbanceParams, + CLARIOstarDriver, + CLARIOstarFluorescenceBackend, + CLARIOstarLuminescenceBackend, +) diff --git a/pylabrobot/bmg_labtech/clariostar/__init__.py b/pylabrobot/bmg_labtech/clariostar/__init__.py new file mode 100644 index 00000000000..53da11172e5 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/__init__.py @@ -0,0 +1,5 @@ +from .absorbance_backend import CLARIOstarAbsorbanceBackend, CLARIOstarAbsorbanceParams +from .clariostar import CLARIOstar +from .driver import CLARIOstarDriver +from .fluorescence_backend import CLARIOstarFluorescenceBackend +from .luminescence_backend import CLARIOstarLuminescenceBackend diff --git a/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py new file mode 100644 index 00000000000..9166c69b1b7 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py @@ -0,0 +1,122 @@ +import logging +import math +import struct +import sys +import time +from dataclasses import dataclass +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceBackend, AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.list import reshape_2d + +from .driver import CLARIOstarDriver + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +logger = logging.getLogger(__name__) + + +@dataclass +class CLARIOstarAbsorbanceParams(BackendParams): + """CLARIOstar-specific parameters for absorbance reads. + + Args: + report: Report type. ``"OD"`` for optical density (absorbance) or + ``"transmittance"`` for transmittance values. Default ``"OD"``. + """ + + report: Literal["OD", "transmittance"] = "OD" + + +class CLARIOstarAbsorbanceBackend(AbsorbanceBackend): + """Translates AbsorbanceBackend interface into CLARIOstar driver commands.""" + + def __init__(self, driver: CLARIOstarDriver): + self.driver = driver + + # Keep the nested class for backward compat with the legacy wrapper that references + # ``CLARIOstarBackend.AbsorbanceParams``. The canonical name is now + # ``CLARIOstarAbsorbanceParams`` (module-level). + AbsorbanceParams = CLARIOstarAbsorbanceParams + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + if not isinstance(backend_params, CLARIOstarAbsorbanceParams): + backend_params = CLARIOstarAbsorbanceParams() + + if wells != plate.get_all_items(): + raise NotImplementedError("Only full plate reads are supported for now.") + + logger.info( + "[CLARIOstar %s] read absorbance: plate=%s, wavelength=%d nm, report=%s, wells=%d", + self.driver.io.device_id or "default", + plate.name, + wavelength, + backend_params.report, + len(wells), + ) + await self.driver.mp_and_focus_height_value() + + wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") + plate_bytes = self.driver.plate_bytes(plate) + payload = ( + b"\x04" + plate_bytes + b"\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27\x0f\x27" + b"\x0f\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" + b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00" + ) + await self.driver.run_measurement(payload) + await self.driver.read_order_values() + await self.driver.status_hw() + + vals = await self.driver.request_measurement_values() + num_wells = plate.num_items + div = b"\x00" * 6 + start_idx = vals.index(div) + len(div) + chromatic_data = vals[start_idx : start_idx + num_wells * 4] + ref_data = vals[start_idx + num_wells * 4 : start_idx + (num_wells * 2) * 4] + chromatic_bytes = [bytes(chromatic_data[i : i + 4]) for i in range(0, len(chromatic_data), 4)] + ref_bytes = [bytes(ref_data[i : i + 4]) for i in range(0, len(ref_data), 4)] + chromatic_reading = [struct.unpack(">i", x)[0] for x in chromatic_bytes] + reference_reading = [struct.unpack(">i", x)[0] for x in ref_bytes] + + after_values_idx = start_idx + (num_wells * 2) * 4 + c100, c0, r100, r0 = struct.unpack(">iiii", vals[after_values_idx : after_values_idx + 4 * 4]) + + real_chromatic_reading = [(cr - c0) / c100 for cr in chromatic_reading] + real_reference_reading = [(rr - r0) / r100 for rr in reference_reading] + + transmittance: List[Optional[float]] = [ + rcr / rrr * 100 for rcr, rrr in zip(real_chromatic_reading, real_reference_reading) + ] + + data: List[List[Optional[float]]] + if backend_params.report == "OD": + od: List[Optional[float]] = [ + math.log10(100 / t) if t is not None and t > 0 else None for t in transmittance + ] + data = reshape_2d(od, (plate.num_items_y, plate.num_items_x)) + elif backend_params.report == "transmittance": + data = reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) + else: + raise ValueError(f"Invalid report type: {backend_params.report}") + + return [ + AbsorbanceResult( + data=data, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/bmg_labtech/clariostar/clariostar.py b/pylabrobot/bmg_labtech/clariostar/clariostar.py new file mode 100644 index 00000000000..9d6e0636bc3 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/clariostar.py @@ -0,0 +1,61 @@ +from typing import Optional + +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from .absorbance_backend import CLARIOstarAbsorbanceBackend +from .driver import CLARIOstarDriver +from .fluorescence_backend import CLARIOstarFluorescenceBackend +from .luminescence_backend import CLARIOstarLuminescenceBackend + + +class CLARIOstar(Resource, Device): + """BMG Labtech CLARIOstar plate reader.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + driver = CLARIOstarDriver(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="BMG CLARIOstar", + ) + Device.__init__(self, driver=driver) + self.driver: CLARIOstarDriver = driver + self.absorbance = Absorbance(backend=CLARIOstarAbsorbanceBackend(driver)) + self.luminescence = Luminescence(backend=CLARIOstarLuminescenceBackend(driver)) + self.fluorescence = Fluorescence(backend=CLARIOstarFluorescenceBackend(driver)) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, # TODO: measure + size_y=85.48, # TODO: measure + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self) -> None: + """Open the plate tray.""" + await self.driver.open() + + async def close(self) -> None: + """Close the plate tray.""" + await self.driver.close() diff --git a/pylabrobot/bmg_labtech/clariostar/driver.py b/pylabrobot/bmg_labtech/clariostar/driver.py new file mode 100644 index 00000000000..bb9e5b6a84c --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/driver.py @@ -0,0 +1,204 @@ +import asyncio +import logging +import time +from typing import Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources.plate import Plate + +logger = logging.getLogger(__name__) + + +class CLARIOstarDriver(Driver): + """FTDI-based driver for the BMG Labtech CLARIOstar plate reader. + + Owns the USB connection, low-level protocol framing, and device-level + operations (initialize, open/close tray). Communicates over FTDI USB + (VID 0x0403, PID 0xBB68) at 125000 baud. + """ + + def __init__(self, device_id: Optional[str] = None): + super().__init__() + self.io = FTDI( + human_readable_device_name="BMG CLARIOstar", device_id=device_id, vid=0x0403, pid=0xBB68 + ) + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + await self.io.setup() + await self.io.set_baudrate(125000) + await self.io.set_line_property(8, 0, 0) # 8N1 + await self.io.set_latency_timer(2) + + await self._initialize() + await self._request_eeprom_data() + logger.info("[CLARIOstar %s] connected", self.io.device_id or "default") + + async def stop(self) -> None: + await self.io.stop() + + # -- Low-level protocol --------------------------------------------------- + + async def read_resp(self, timeout: float = 20) -> bytes: + """Read a response terminated by 0x0D.""" + d = b"" + last_read = b"" + end_byte_found = False + t = time.time() + + while True: + last_read = await self.io.read(25) + if len(last_read) > 0: + d += last_read + end_byte_found = d[-1] == 0x0D + if len(last_read) < 25 and end_byte_found: + break + else: + if end_byte_found: + break + if time.time() - t > timeout: + logger.warning("timed out reading response") + break + await asyncio.sleep(0.0001) + + logger.debug("read %s", d.hex()) + return d + + async def send(self, cmd: Union[bytearray, bytes], read_timeout: float = 20) -> bytes: + """Send a command with 16-bit checksum + 0x0D terminator and return the response.""" + checksum = (sum(cmd) & 0xFFFF).to_bytes(2, byteorder="big") + cmd = cmd + checksum + b"\x0d" + logger.debug("sending %s", cmd.hex()) + w = await self.io.write(cmd) + logger.debug("wrote %s bytes", w) + assert w == len(cmd) + return await self.read_resp(timeout=read_timeout) + + async def _wait_for_ready_and_return(self, ret: bytes, timeout: float = 150) -> bytes: + """Poll command status until the device reports ready.""" + last_status = None + t = time.time() + while time.time() - t < timeout: + await asyncio.sleep(0.1) + command_status = await self._read_command_status() + + if len(command_status) != 24: + logger.warning( + "unexpected response %s. Expected 24 bytes for command status.", command_status + ) + continue + + if command_status != last_status: + logger.info("status changed %s", command_status.hex()) + last_status = command_status + else: + continue + + if command_status[2] != 0x18 or command_status[3] != 0x0C or command_status[4] != 0x01: + logger.warning("unexpected response header %s", command_status) + + if command_status[5] not in {0x25, 0x05}: + logger.warning("unexpected status byte %s", command_status) + + if command_status[5] == 0x05: + logger.debug("status is ready") + return ret + + raise TimeoutError("CLARIOstar did not become ready within timeout.") + + async def _read_command_status(self) -> bytes: + return await self.send(b"\x02\x00\x09\x0c\x80\x00") + + async def _initialize(self) -> None: + command_response = await self.send(b"\x02\x00\x0d\x0c\x01\x00\x00\x10\x02\x00") + await self._wait_for_ready_and_return(command_response) + + async def _request_eeprom_data(self) -> None: + eeprom_response = await self.send(b"\x02\x00\x0f\x0c\x05\x07\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(eeprom_response) + + # -- Tray control --------------------------------------------------------- + + async def open(self) -> None: + """Open the plate tray.""" + logger.info("[CLARIOstar %s] open tray", self.io.device_id or "default") + open_response = await self.send(b"\x02\x00\x0e\x0c\x03\x01\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(open_response) + + async def close(self) -> None: + """Close the plate tray.""" + logger.info("[CLARIOstar %s] close tray", self.io.device_id or "default") + close_response = await self.send(b"\x02\x00\x0e\x0c\x03\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(close_response) + + # -- Helpers used by capability backends ---------------------------------- + + async def mp_and_focus_height_value(self) -> None: + resp = await self.send(b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(resp) + + async def read_order_values(self) -> bytes: + return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") + + async def status_hw(self) -> bytes: + resp = await self.send(b"\x02\x00\x09\x0c\x81\x00") + return await self._wait_for_ready_and_return(resp) + + async def request_measurement_values(self) -> bytes: + return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") + + def plate_bytes(self, plate: Plate) -> bytes: + """Encode plate geometry into the 62-byte binary format.""" + + def float_to_bytes(f: float) -> bytes: + return round(f * 100).to_bytes(2, byteorder="big") + + plate_length = plate.get_absolute_size_x() + plate_width = plate.get_absolute_size_y() + + well_0 = plate.get_well(0) + assert well_0.location is not None, "Well 0 must be assigned to a plate" + plate_x1 = well_0.location.x + well_0.center().x + plate_y1 = plate_width - (well_0.location.y + well_0.center().y) + plate_xn = plate_length - plate_x1 + plate_yn = plate_width - plate_y1 + + plate_cols = plate.num_items_x + plate_rows = plate.num_items_y + + wells = ([1] * plate.num_items) + ([0] * (384 - plate.num_items)) + well_mask: int = sum(b << i for i, b in enumerate(wells[::-1])) + wells_bytes = well_mask.to_bytes(48, "big") + + return ( + float_to_bytes(plate_length) + + float_to_bytes(plate_width) + + float_to_bytes(plate_x1) + + float_to_bytes(plate_y1) + + float_to_bytes(plate_xn) + + float_to_bytes(plate_yn) + + plate_cols.to_bytes(1, byteorder="big") + + plate_rows.to_bytes(1, byteorder="big") + + wells_bytes + ) + + async def run_measurement(self, payload: bytes) -> bytes: + """Execute a measurement run and poll until complete.""" + message_size = (len(payload) + 7).to_bytes(2, byteorder="big") + cmd = b"\x02" + message_size + b"\x0c" + payload + run_response = await self.send(cmd) + + last_status = None + while True: + await asyncio.sleep(0.1) + command_status = await self._read_command_status() + if command_status != last_status: + last_status = command_status + logger.info("status changed %s", command_status) + continue + if command_status == bytes( + b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" + b"\x00\x00\x00\xc0\x00\x01\x46\x0d" + ): + return run_response diff --git a/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py b/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py new file mode 100644 index 00000000000..54f6c39c1fa --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.fluorescence import ( + FluorescenceBackend, + FluorescenceResult, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .driver import CLARIOstarDriver + + +class CLARIOstarFluorescenceBackend(FluorescenceBackend): + """Translates FluorescenceBackend interface into CLARIOstar driver commands.""" + + def __init__(self, driver: CLARIOstarDriver): + self.driver = driver + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + raise NotImplementedError("CLARIOstar fluorescence reading is not implemented yet.") diff --git a/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py new file mode 100644 index 00000000000..5701a5a0e41 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py @@ -0,0 +1,74 @@ +import logging +import struct +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.luminescence import ( + LuminescenceBackend, + LuminescenceResult, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.list import reshape_2d + +from .driver import CLARIOstarDriver + +logger = logging.getLogger(__name__) + + +class CLARIOstarLuminescenceBackend(LuminescenceBackend): + """Translates LuminescenceBackend interface into CLARIOstar driver commands.""" + + def __init__(self, driver: CLARIOstarDriver): + self.driver = driver + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float = 13, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + if wells != plate.get_all_items(): + raise NotImplementedError("Only full plate reads are supported for now.") + + logger.info( + "[CLARIOstar %s] read luminescence: plate=%s, focal_height=%.1f mm, wells=%d", + self.driver.io.device_id or "default", + plate.name, + focal_height, + len(wells), + ) + await self.driver.mp_and_focus_height_value() + + assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" + focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") + plate_bytes = self.driver.plate_bytes(plate) + payload = ( + b"\x04" + plate_bytes + b"\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" + b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01" + b"\x00\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00" + b"\x00\x01\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" + ) + await self.driver.run_measurement(payload) + await self.driver.read_order_values() + await self.driver.status_hw() + + vals = await self.driver.request_measurement_values() + num_wells = plate.num_items + start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") + data = list(vals)[start_idx : start_idx + num_wells * 4] + int_bytes = [data[i : i + 4] for i in range(0, len(data), 4)] + ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] + floats: List[List[Optional[float]]] = reshape_2d( + [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) + ) + + return [ + LuminescenceResult( + data=floats, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/brooks/__init__.py b/pylabrobot/brooks/__init__.py new file mode 100644 index 00000000000..37b32a83502 --- /dev/null +++ b/pylabrobot/brooks/__init__.py @@ -0,0 +1,6 @@ +from .precise_flex import ( + PreciseFlex400, + PreciseFlex3400Backend, + PreciseFlexArmBackend, + PreciseFlexDriver, +) diff --git a/pylabrobot/brooks/error_codes.py b/pylabrobot/brooks/error_codes.py new file mode 100644 index 00000000000..c254e5418c9 --- /dev/null +++ b/pylabrobot/brooks/error_codes.py @@ -0,0 +1,1921 @@ +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.", + }, +} diff --git a/pylabrobot/brooks/kinematics.py b/pylabrobot/brooks/kinematics.py new file mode 100644 index 00000000000..2a954bbe7f8 --- /dev/null +++ b/pylabrobot/brooks/kinematics.py @@ -0,0 +1,141 @@ +""" +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, hypot, pi, radians, degrees, sin +from typing import TYPE_CHECKING + +from pylabrobot.capabilities.arms.standard import JointPose + +if TYPE_CHECKING: + from pylabrobot.brooks.precise_flex import PreciseFlexCartesianPose + + +@dataclass +class PF400Params: + """Calibrated link lengths; sub-mm FK residual on a held-out probe set.""" + + l1: float = 302.0 # shoulder -> elbow [mm] + l2: float = 289.0 # elbow -> wrist [mm] + gripper_length: float = 162.0 # wrist -> TCP [mm] + gripper_z_offset: float = 0.0 + eps: float = 1e-6 + + +class IKError(ValueError): + """Target pose is unreachable.""" + + +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). + """ + from pylabrobot.brooks.precise_flex import PreciseFlexCartesianPose + from pylabrobot.resources import Coordinate, Rotation + + 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 = "right" if j3_wrapped >= 0 else "left" + 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, + ) + + +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/pf400_test.ipynb b/pylabrobot/brooks/pf400_test.ipynb new file mode 100644 index 00000000000..ae984684660 --- /dev/null +++ b/pylabrobot/brooks/pf400_test.ipynb @@ -0,0 +1,329 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# PreciseFlex 400 — Teaching & Freedrive Notebook\n", + "\n", + "Uses the `JointArm` frontend with a `PreciseFlex400Backend`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "from pylabrobot.arms.joint_arm import JointArm\n", + "from pylabrobot.brooks.precise_flex import (\n", + " ElbowOrientation,\n", + " PFAxis,\n", + " PreciseFlex400Backend,\n", + " PreciseFlexBackend,\n", + ")\n", + "from pylabrobot.resources import Coordinate, Resource, Rotation\n", + "\n", + "POSITIONS_FILE = Path(\"positions.json\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def save_position(name: str, pos):\n", + " \"\"\"Save a named position to disk.\"\"\"\n", + " positions = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {}\n", + " positions[name] = {\n", + " \"x\": pos.location.x,\n", + " \"y\": pos.location.y,\n", + " \"z\": pos.location.z,\n", + " \"roll\": pos.rotation.x,\n", + " \"pitch\": pos.rotation.y,\n", + " \"yaw\": pos.rotation.z,\n", + " \"orientation\": pos.orientation.value if pos.orientation else None,\n", + " }\n", + " POSITIONS_FILE.write_text(json.dumps(positions, indent=2))\n", + " print(f\"Saved '{name}'\")\n", + "\n", + "\n", + "def load_position(name: str):\n", + " \"\"\"Load a named position from disk.\"\"\"\n", + " positions = json.loads(POSITIONS_FILE.read_text())\n", + " p = positions[name]\n", + " return (\n", + " Coordinate(p[\"x\"], p[\"y\"], p[\"z\"]),\n", + " Rotation(p[\"roll\"], p[\"pitch\"], p[\"yaw\"]),\n", + " ElbowOrientation(p[\"orientation\"]) if p[\"orientation\"] else None,\n", + " )\n", + "\n", + "\n", + "def list_positions():\n", + " \"\"\"List all saved positions.\"\"\"\n", + " if not POSITIONS_FILE.exists():\n", + " print(\"No saved positions.\")\n", + " return\n", + " positions = json.loads(POSITIONS_FILE.read_text())\n", + " for name, p in positions.items():\n", + " print(\n", + " f\" {name}: x={p['x']:.1f} y={p['y']:.1f} z={p['z']:.1f} \"\n", + " f\"yaw={p['yaw']:.1f} {p['orientation'] or ''}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connect" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "backend = PreciseFlex400Backend(host=\"192.168.0.1\")\nreference = Resource(\"workcell\", size_x=2000, size_y=2000, size_z=0)\narm = JointArm(backend=backend, reference_resource=reference)\n\nawait arm.setup()\nprint(f\"Version: {await backend.request_version()}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Read current position" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "cart = await arm.request_gripper_location()\nprint(f\"Cartesian: x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}\")\nprint(\n f\"Rotation: yaw={cart.rotation.z:.1f}, pitch={cart.rotation.y:.1f}, roll={cart.rotation.x:.1f}\"\n)\nprint(f\"Elbow: {cart.orientation}\")\nprint()\njoints = await arm.request_joint_position()\nprint(f\"Joints: {joints}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Freedrive mode\n", + "\n", + "Enter freedrive to manually position the arm. Use `[0]` to free all axes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.start_freedrive_mode([0])\n", + "print(\"Freedrive ON -- move the arm manually\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Read position while in freedrive\ncart = await arm.request_gripper_location()\nprint(\n f\"x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}, \"\n f\"yaw={cart.rotation.z:.1f}, orientation={cart.orientation}\"\n)" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.stop_freedrive_mode()\n", + "print(\"Freedrive OFF\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teach & save positions\n", + "\n", + "Use freedrive to move the arm, then save the position to disk." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Save current position with a name\npos = await arm.request_gripper_location()\nsave_position(\"home\", pos)" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Save another position\npos = await arm.request_gripper_location()\nsave_position(\"plate_pickup\", pos)" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "list_positions()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Replay saved positions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loc, rot, orientation = load_position(\"home\")\n", + "await arm.move_to_location(\n", + " location=loc,\n", + " direction=rot,\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(orientation=orientation, speed=30),\n", + ")\n", + "print(\"Moved to 'home'\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loc, rot, orientation = load_position(\"plate_pickup\")\n", + "await arm.move_to_location(\n", + " location=loc,\n", + " direction=rot,\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(orientation=orientation, speed=30),\n", + ")\n", + "print(\"Moved to 'plate_pickup'\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Move with speed control" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await arm.move_to_location(\n location=Coordinate(300, 0, 200),\n direction=Rotation(0, 0, 0),\n backend_params=PreciseFlexBackend.MoveToLocationParams(speed=20),\n)\nprint(await arm.request_gripper_location())" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Lefty / Righty\n", + "\n", + "Pass `ElbowOrientation` through `backend_params`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await arm.move_to_location(\n location=Coordinate(300, 0, 200),\n direction=Rotation(0, 0, 0),\n backend_params=PreciseFlexBackend.MoveToLocationParams(\n orientation=ElbowOrientation.RIGHT,\n speed=30,\n ),\n)\nprint(\"Righty:\", await arm.request_gripper_location())" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await arm.move_to_location(\n location=Coordinate(300, 0, 200),\n direction=Rotation(0, 0, 0),\n backend_params=PreciseFlexBackend.MoveToLocationParams(\n orientation=ElbowOrientation.LEFT,\n speed=30,\n ),\n)\nprint(\"Lefty:\", await arm.request_gripper_location())" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await backend.change_config()\nprint(\"Flipped:\", await arm.request_gripper_location())" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Move joints" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "await arm.move_to_joint_position(\n {PFAxis.BASE: 45.0},\n backend_params=PreciseFlexBackend.MoveToJointPositionParams(speed=30),\n)\nprint(await arm.request_joint_position())" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Gripper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.open_gripper(gripper_width=80.0)\n", + "print(f\"Gripper closed: {await arm.is_gripper_closed()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.close_gripper(gripper_width=10.0)\n", + "print(f\"Gripper closed: {await arm.is_gripper_closed()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Disconnect" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.move_to_safe()\n", + "await arm.stop()\n", + "print(\"Done\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/pylabrobot/brooks/precise_flex.py b/pylabrobot/brooks/precise_flex.py new file mode 100644 index 00000000000..3ee098df369 --- /dev/null +++ b/pylabrobot/brooks/precise_flex.py @@ -0,0 +1,1758 @@ +import asyncio +import dataclasses +import logging +import warnings +from abc import ABC +from dataclasses import dataclass +from enum import IntEnum +from typing import Dict, List, Literal, Optional + +from pylabrobot.brooks.error_codes import ERROR_CODES +from pylabrobot.brooks import kinematics +from pylabrobot.capabilities.arms.backend import ( + CanFreedrive, + HasJoints, + OrientableGripperArmBackend, +) +from pylabrobot.capabilities.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.arms.standard import CartesianPose, JointPose +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Device, Driver +from pylabrobot.io.socket import Socket +from pylabrobot.resources import Coordinate, Rotation +from pylabrobot.resources.resource import Resource + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Supporting types +# --------------------------------------------------------------------------- + + +ElbowOrientation = Literal["right", "left"] +Wrist = Literal["cw", "ccw"] + + +class Axis(IntEnum): + BASE = 1 + SHOULDER = 2 + ELBOW = 3 + WRIST = 4 + GRIPPER = 5 + RAIL = 6 + + +@dataclass +class PreciseFlexCartesianPose(CartesianPose): + rail_position: Optional[float] = None + orientation: Optional[ElbowOrientation] = None + wrist: Optional[Wrist] = None + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +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}") + + +# --------------------------------------------------------------------------- +# Driver — owns Socket I/O and device lifecycle +# --------------------------------------------------------------------------- + + +class PreciseFlexDriver(Driver): + """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 + """ + + def __init__(self, host: str, port: int = 10100, timeout: int = 20) -> None: + super().__init__() + self.io = Socket(human_readable_device_name="Precise Flex Arm", host=host, port=port) + self.timeout = timeout + + # -- 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 ------------------------------------------------------------- + + @dataclass + class SetupParams(BackendParams): + """PreciseFlex-specific parameters for ``setup``. + + Args: + skip_home: If True, skip the homing step during setup. + """ + + skip_home: bool = False + + async def setup(self, backend_params: Optional[BackendParams] = None): + """Initialize the PreciseFlex driver. + + Opens the socket connection, sets response mode to PC, powers on the + robot, attaches it, and (optionally) homes it. + """ + if not isinstance(backend_params, PreciseFlexDriver.SetupParams): + backend_params = PreciseFlexDriver.SetupParams() + + await self.io.setup() + await self.set_response_mode("pc") + await self.power_on_robot() + await self.attach(1) + if not backend_params.skip_home: + await self.home() + logger.info("[PreciseFlex %s] connected: port=%s", self.io._host, self.io._port) + + 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, PreciseFlexDriver.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 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 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) -> None: + """Wait for the robot to reach the end of the current motion. + + Waits for the robot to reach the end of the current motion or until it is stopped by + some other means. Does not reply until the robot has stopped. + """ + await self.send_command("waitForEom") + await asyncio.sleep(0.2) + + async def 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") + + +# --------------------------------------------------------------------------- +# Arm Backend — protocol translation, capability methods +# --------------------------------------------------------------------------- + + +def _snap_to_current(ik_joints: JointPose, current: JointPose, wrist: 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 PreciseFlexArmBackend(OrientableGripperArmBackend, HasJoints, CanFreedrive, ABC): + """Backend for the PreciseFlex robotic arm. + + Default to using Cartesian coordinates; some methods in Brook's TCS + don't work with Joint coordinates. + + Documentation and error codes available at + https://www2.brooksautomation.com/#Root/Welcome.htm + """ + + def __init__( + self, + driver: PreciseFlexDriver, + gripper_length: float, + gripper_z_offset: float, + is_dual_gripper: bool = False, + has_rail: bool = False, + ) -> None: + """ + Args: + gripper_length: wrist-axis → TCP distance in mm. Depends on the mounted + gripper; the concrete Device wrapper supplies a model-appropriate default + (e.g. 162 mm for the stock single gripper on the PF400). + 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. + """ + super().__init__() + self.driver = driver + 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._kinematics_params = kinematics.PF400Params( + gripper_length=gripper_length, gripper_z_offset=gripper_z_offset + ) + if is_dual_gripper: + warnings.warn( + "Dual gripper support is experimental and may not work as expected.", UserWarning + ) + + async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: + await super()._on_setup(backend_params=backend_params) + await self.stop_freedrive_mode() + + 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 + + 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 can use the result directly with `_move_j` or `_set_joint_angles`. + """ + 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) + joints[Axis.BASE] = ik_joints[1] + joints[Axis.SHOULDER] = ik_joints[2] + joints[Axis.ELBOW] = ik_joints[3] + joints[Axis.WRIST] = ik_joints[4] + joints[Axis.RAIL] = cart.rail_position + return joints + + # -- high-level motion API ------------------------------------------------- + + 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) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ): + """Open the gripper to the specified width.""" + logger.info("[PreciseFlex %s] open_gripper: width_mm=%s", self.driver.io._host, gripper_width) + await self._set_grip_open_pos(gripper_width) + await self.driver.send_command("gripper 1") + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ): + """Close the gripper to the specified width.""" + logger.info("[PreciseFlex %s] close_gripper: width_mm=%s", self.driver.io._host, gripper_width) + await self._set_grip_close_pos(gripper_width) + await self.driver.send_command("gripper 2") + + async def halt(self, backend_params: Optional[BackendParams] = None): + """Stops the current robot immediately but leaves power on.""" + await self.driver.send_command("halt") + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Move the robot to its parking position. + + Does not include checks for collision with 3rd party obstacles inside the work volume of the robot. + """ + await self.driver.send_command("movetosafe") + + 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) + + # -- JointArmBackend interface (joint-space) -------------------------------- + + @dataclass + class PickUpParams(BackendParams): + """PreciseFlex arm parameters for plate pickup. + + Args: + finger_speed_pct: Finger closing speed as a percentage (0-100). Default 50.0. + grasp_force: Grasp force in Newtons. Default 10.0. + orientation: Elbow orientation (``"lefty"`` or ``"righty"``). If None, the robot + picks the closest configuration. Only used for Cartesian moves. + rail_position: Linear rail position in mm. Required when the arm has a rail. + Only used for Cartesian moves. + """ + + finger_speed_pct: float = 50.0 + grasp_force: float = 10.0 + orientation: Optional[ElbowOrientation] = None + wrist: Optional[Wrist] = None + rail_position: Optional[float] = None + + async def pick_up_at_joint_position( + self, + position: JointPose, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified joint position.""" + logger.info( + "[PreciseFlex %s] pick_up: joints=%s, resource_width_mm=%s", + self.driver.io._host, + position, + resource_width, + ) + if not isinstance(backend_params, self.PickUpParams): + backend_params = PreciseFlexArmBackend.PickUpParams() + await self._set_grasp_data( + plate_width=resource_width, + finger_speed_pct=backend_params.finger_speed_pct, + grasp_force=backend_params.grasp_force, + ) + await self._pick_plate_j(position) + + @dataclass + class DropParams(BackendParams): + """PreciseFlex arm parameters for plate drop. + + Args: + orientation: Elbow orientation (``"lefty"`` or ``"righty"``). If None, the robot + picks the closest configuration. Only used for Cartesian moves. + rail_position: Linear rail position in mm. Required when the arm has a rail. + Only used for Cartesian moves. + """ + + orientation: Optional[ElbowOrientation] = None + wrist: Optional[Wrist] = None + rail_position: Optional[float] = None + + async def drop_at_joint_position( + self, + position: JointPose, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified joint position.""" + logger.info( + "[PreciseFlex %s] drop: joints=%s, resource_width_mm=%s", + self.driver.io._host, + position, + resource_width, + ) + if not isinstance(backend_params, self.DropParams): + backend_params = PreciseFlexArmBackend.DropParams() + await self._place_plate_j(position) + + @dataclass + class MoveToJointPositionParams(BackendParams): + """PreciseFlex arm parameters for joint-space moves. + + Args: + speed_pct: Movement speed override as a percentage (0-100). If None, uses the current speed setting. + """ + + speed_pct: Optional[float] = None + + async def move_to_joint_position( + self, + position: JointPose, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the arm to the specified joint position.""" + if not isinstance(backend_params, self.MoveToJointPositionParams): + backend_params = PreciseFlexArmBackend.MoveToJointPositionParams() + if backend_params.speed_pct is not None: + await self._set_speed(backend_params.speed_pct) + current = await self.request_joint_position() + joint_coords = {**current, **position} + await self._move_j(profile_index=self.profile_index, joint_coords=joint_coords) + + async def request_joint_position( + self, backend_params: Optional[BackendParams] = None + ) -> JointPose: + """Get the current joint position of the arm.""" + await self.driver._wait_for_eom() + num_tries = 2 + for _ in range(num_tries): + data = await self.driver.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 request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> PreciseFlexCartesianPose: + """Get the current pose using our kinematics model (no firmware `wherec`).""" + _, pose = await self._request_state() + return pose + + # -- OrientableArmBackend interface (Cartesian) ----------------------------- + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified Cartesian location.""" + logger.info( + "[PreciseFlex %s] pick_up: x=%s, y=%s, z=%s, direction=%s, resource_width_mm=%s", + self.driver.io._host, + location.x, + location.y, + location.z, + direction, + resource_width, + ) + if not isinstance(backend_params, self.PickUpParams): + backend_params = PreciseFlexArmBackend.PickUpParams() + if backend_params.rail_position is not None: + await self.move_rail(backend_params.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=backend_params.orientation, + wrist=backend_params.wrist, + ) + await self._set_grasp_data( + plate_width=resource_width, + finger_speed_pct=backend_params.finger_speed_pct, + grasp_force=backend_params.grasp_force, + ) + await self._pick_plate_c(cartesian_position=coords) + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified Cartesian location.""" + logger.info( + "[PreciseFlex %s] drop: x=%s, y=%s, z=%s, direction=%s, resource_width_mm=%s", + self.driver.io._host, + location.x, + location.y, + location.z, + direction, + resource_width, + ) + if not isinstance(backend_params, self.DropParams): + backend_params = PreciseFlexArmBackend.DropParams() + if backend_params.rail_position is not None: + await self.move_rail(backend_params.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=backend_params.orientation, + wrist=backend_params.wrist, + ) + await self._place_plate_c(cartesian_position=coords) + + @dataclass + class MoveToLocationParams(BackendParams): + """PreciseFlex arm parameters for Cartesian-space moves. + + Args: + 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. + rail_position: Linear rail position in mm. Required when the arm has a rail. + """ + + speed_pct: Optional[float] = None + orientation: Optional[ElbowOrientation] = None + wrist: Optional[Wrist] = None + rail_position: Optional[float] = None + + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the arm to the specified Cartesian location.""" + if not isinstance(backend_params, self.MoveToLocationParams): + backend_params = PreciseFlexArmBackend.MoveToLocationParams() + if backend_params.speed_pct is not None: + await self._set_speed(backend_params.speed_pct) + + if backend_params.rail_position is not None: + await self.move_rail(backend_params.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=backend_params.orientation, + wrist=backend_params.wrist, + ) + joints = await self._cart_to_joints(coords) + await self._move_j(profile_index=self.profile_index, joint_coords=joints) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> 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.driver.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.driver.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) + + async def start_freedrive_mode( + self, free_axes: Optional[List[int]] = None, backend_params=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. + """ + for axis in free_axes or [ + Axis.BASE, + Axis.SHOULDER, + Axis.ELBOW, + Axis.WRIST, + Axis.RAIL, + ]: + await self.driver.send_command(f"freemode {axis}") + + async def stop_freedrive_mode(self, backend_params=None) -> None: + """Exit freedrive mode for all axes.""" + await self.driver.send_command("freemode -1") + + # -- internal pick/place helpers ------------------------------------------- + + 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.driver.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.driver.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) + + async def _set_grip_detail(self): + """Configure a default vertical station type for pick/place operations.""" + await self.driver.send_command(f"StationType {self.location_index} 1 0 100 0 10") + + # -- GENERAL COMMANDS ------------------------------------------------------ + + 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.driver.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.driver.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") + + 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.driver.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.driver.send_command(f"mspeed {speed_pct}") + + 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.driver.send_command("nop") + + 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.driver.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.driver.send_command(f"payload {payload_pct}") + + 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.driver.send_command( + f'pc {data_id} {unit_number} {sub_unit} {array_index} "{value}"' + ) + else: + await self.driver.send_command( + f"pc {data_id} {unit_number} {sub_unit} {array_index} {value}" + ) + else: + if isinstance(value, str): + await self.driver.send_command(f'pc {data_id} "{value}"') + else: + await self.driver.send_command(f"pc {data_id} {value}") + + 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.driver.send_command( + f"pd {data_id} {unit_number} {sub_unit} {array_index}" + ) + else: + response = await self.driver.send_command(f"pd {data_id} {unit_number} {sub_unit}") + else: + response = await self.driver.send_command(f"pd {data_id} {unit_number}") + else: + response = await self.driver.send_command(f"pd {data_id}") + return response + + 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.driver.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.driver.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.driver.send_command(f"selectRobot {robot_number}") + + 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.driver.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.driver.send_command(f"sig {signal_number} {value}") + + async def request_system_state(self) -> int: + """Get the global system state code. + + Returns: + The global system state code. Please see documentation for DataID 234. + """ + response = await self.driver.send_command("sysState") + return int(response) + + 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.driver.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. + + The robot must be attached to set the tool. Setting the tool 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.driver.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") + + 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.driver.send_command("version") + + # -- LOCATION COMMANDS ----------------------------------------------------- + + 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.driver.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.driver.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 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.driver.send_command("destC") + else: + data = await self.driver.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.driver.send_command("destJ") + else: + data = await self.driver.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.driver.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.driver.send_command(f"hereC {location_index}") + + # -- PROFILE COMMANDS ------------------------------------------------------ + + 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.driver.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.driver.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.driver.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.driver.send_command(f"Speed2 {profile_index} {speed2_pct}") + + async def request_profile_accel(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.driver.send_command(f"Accel {profile_index}") + profile, accel = response.split() + return float(accel) + + async def set_profile_accel(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.driver.send_command(f"Accel {profile_index} {acceleration_pct}") + + async def request_profile_accel_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.driver.send_command(f"AccRamp {profile_index}") + profile, accel_ramp = response.split() + return float(accel_ramp) + + async def set_profile_accel_ramp(self, profile_index: int, accel_ramp_seconds: float) -> None: + """Set the acceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to modify. + accel_ramp_seconds: The new acceleration ramp time in seconds. + """ + await self.driver.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") + + async def request_profile_decel(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.driver.send_command(f"Decel {profile_index}") + profile, decel = response.split() + return float(decel) + + async def set_profile_decel(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.driver.send_command(f"Decel {profile_index} {deceleration_pct}") + + async def request_profile_decel_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.driver.send_command(f"DecRamp {profile_index}") + profile, decel_ramp = response.split() + return float(decel_ramp) + + async def set_profile_decel_ramp(self, profile_index: int, decel_ramp_seconds: float) -> None: + """Set the deceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to modify. + decel_ramp_seconds: The new deceleration ramp time in seconds. + """ + await self.driver.send_command(f"DecRamp {profile_index} {decel_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.driver.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.driver.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.driver.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.driver.send_command(f"Straight {profile_index} {straight_int}") + + 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 accel. + deceleration_pct: Percentage of maximum deceleration (0-100). 100 = full decel. + 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.driver.send_command( + f"Profile {profile} {speed_pct} {speed2_pct} {acceleration_pct} {deceleration_pct} " + f"{acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" + ) + + async def request_motion_profile_values( + self, profile: int + ) -> tuple[int, float, float, float, float, float, float, float, bool]: + """ + 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 tuple containing (profile, speed, speed2, acceleration, deceleration, acceleration_ramp, deceleration_ramp, in_range, straight) + - profile: Profile index + - speed: Percentage of maximum speed + - speed2: Secondary speed setting + - acceleration: Percentage of maximum acceleration + - deceleration: Percentage of maximum deceleration + - acceleration_ramp: Acceleration ramp time in seconds + - deceleration_ramp: Deceleration ramp time in seconds + - in_range: InRange value (-1 to 100) + - straight: True if straight-line path, False if joint-based path + """ + data = await self.driver.send_command(f"Profile {profile}") + parts = data.split(" ") + if len(parts) != 9: + raise PreciseFlexError(-1, "Unexpected response format from device.") + return ( + 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, + ) + + # -- RAIL COMMANDS --------------------------------------------------------- + + 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.driver.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.driver.send_command(f"MoveRail {station_id} {mode}") + else: + await self.driver.send_command(f"MoveRail {mode}") + + # -- MOTION COMMANDS ------------------------------------------------------- + + 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.driver.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.driver.send_command(f"moveAppro {location_index} {profile_index}") + + async def _move_j(self, profile_index: int, joint_coords: JointPose) -> None: + """Move the robot using joint coordinates, handling rail configuration.""" + 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.driver.send_command(f"moveJ {profile_index} {angles_str}") + + 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.driver.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.driver.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.driver.send_command(f"zeroTorque 1 {axis_mask}") + else: + await self.driver.send_command("zeroTorque 0") + + # -- PAROBOT COMMANDS ------------------------------------------------------ + + 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.driver.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.driver.send_command(f"ChangeConfig2 {grip_mode}") + + 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.driver.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.driver.send_command(f"GraspData {plate_width} {finger_speed_pct} {grasp_force}") + + 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.driver.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.driver.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.driver.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.driver.send_command(f"GripOpenPos {open_position}") + + # -- parsing helpers ------------------------------------------------------- + + 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, + } + + +# --------------------------------------------------------------------------- +# Concrete model backends +# --------------------------------------------------------------------------- + + +class PreciseFlex400Backend(PreciseFlexArmBackend): + """Backend for the PreciseFlex 400 robotic arm.""" + + _PARKING_POSITION: JointPose = { + Axis.BASE: 170.0, + Axis.SHOULDER: 0.0, + Axis.ELBOW: 180.0, + Axis.WRIST: -180.0, + } + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Move the PF400 to its parking position via joint move. + + Sends an explicit joint target instead of the firmware ``movetosafe`` command. + """ + await self.move_to_joint_position(position=self._PARKING_POSITION) + + +class PreciseFlex400(Device): + """Device wrapper for the PreciseFlex 400 robotic arm.""" + + def __init__( + self, + host: str, + port: int = 10100, + has_rail: bool = False, + timeout: int = 20, + gripper_length: float = 162.0, + gripper_z_offset: float = 0.0, + ) -> None: + """ + Args: + gripper_length: wrist-axis → TCP distance in mm. Defaults to 162 mm, which + matches the stock single gripper on the PF400. + gripper_z_offset: vertical offset in mm from the wrist plate to the tool tip. + Defaults to 0 mm. + """ + driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + super().__init__(driver=driver) + self.driver: PreciseFlexDriver = driver + backend = PreciseFlex400Backend( + driver=driver, + has_rail=has_rail, + gripper_length=gripper_length, + gripper_z_offset=gripper_z_offset, + ) + self.reference = Resource(name="PreciseFlex400", size_x=200, size_y=200, size_z=200) + self.arm = OrientableArm(backend=backend, reference_resource=self.reference) + self._capabilities = [self.arm] + + +class PreciseFlex3400Backend(PreciseFlexArmBackend): + """Backend for the PreciseFlex 3400 robotic arm.""" + + def __init__( + self, + driver: PreciseFlexDriver, + gripper_length: float, + gripper_z_offset: float, + has_rail: bool = False, + ) -> None: + super().__init__( + driver=driver, + has_rail=has_rail, + gripper_length=gripper_length, + gripper_z_offset=gripper_z_offset, + ) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py new file mode 100644 index 00000000000..c9289dff529 --- /dev/null +++ b/pylabrobot/byonoy/__init__.py @@ -0,0 +1,21 @@ +from .absorbance_96 import ( + ByonoyAbsorbance96, + ByonoyAbsorbance96Backend, + ByonoyAbsorbanceBaseUnit, + byonoy_a96a, + byonoy_a96a_detection_unit, + byonoy_a96a_illumination_unit, + byonoy_a96a_parking_unit, + byonoy_sbs_adapter, +) +from .luminescence_96 import ( + ByonoyLuminescence96, + ByonoyLuminescence96Backend, + ByonoyLuminescenceBaseUnit, + byonoy_l96, + byonoy_l96_base_unit, + byonoy_l96_reader_unit, + byonoy_l96a, + byonoy_l96a_base_unit, + byonoy_l96a_reader_unit, +) diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py new file mode 100644 index 00000000000..1cc7f59c09a --- /dev/null +++ b/pylabrobot/byonoy/absorbance_96.py @@ -0,0 +1,332 @@ +import logging +import time +from typing import List, Optional, Tuple + +from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.absorbance import ( + Absorbance, + AbsorbanceBackend, + AbsorbanceResult, +) +from pylabrobot.device import Device +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.serializer import SerializableMixin +from pylabrobot.utils.list import reshape_2d + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Backend +# --------------------------------------------------------------------------- + + +class ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend): + """Backend for the Byonoy Absorbance 96 Automate plate reader.""" + + def __init__(self) -> None: + super().__init__(pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96) + self.available_wavelengths: List[float] = [] + + async def setup(self, backend_params: Optional["BackendParams"] = None) -> None: + await super().setup(backend_params=backend_params) + await self.initialize_measurements() + self.available_wavelengths = await self.request_available_absorbance_wavelengths() + logger.info( + "[Byonoy A96 pid=0x%04X] ready, available wavelengths: %s nm", + self.io.pid, + 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): + 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", + ) + + rows: List[float] = [] + t0 = time.time() + + while True: + if time.time() - t0 > 120: + logger.error( + "[Byonoy A96 pid=0x%04X] measurement timed out after 120s (signal=%d nm, ref=%d nm)", + self.io.pid, + 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)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + rows.extend(row) + + if seq == seq_len - 1: + break + + 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, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + assert wavelength in self.available_wavelengths, ( + f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." + ) + + logger.info( + "[Byonoy A96 pid=0x%04X] reading absorbance: plate='%s', wavelength=%d nm, wells=%d/%d", + self.io.pid, + 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=time.time(), + ) + ] + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +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("ByonoyBase 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( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or illumination_unit_holder to assign plates and the " + "illumination unit, respectively." + ) + + +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), + ) + + +# --------------------------------------------------------------------------- +# Device + Resource composite +# --------------------------------------------------------------------------- + + +class ByonoyAbsorbance96(ByonoyAbsorbanceBaseUnit, Device): + """Byonoy Absorbance 96 Automate plate reader.""" + + def __init__(self, name: str = "byonoy_absorbance_96"): + backend = ByonoyAbsorbance96Backend() + ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") + Device.__init__(self, driver=backend) + self.driver: ByonoyAbsorbance96Backend = backend + self.absorbance = Absorbance(backend=backend) + self._capabilities = [self.absorbance] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + +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/backend.py b/pylabrobot/byonoy/backend.py new file mode 100644 index 00000000000..726e7509568 --- /dev/null +++ b/pylabrobot/byonoy/backend.py @@ -0,0 +1,109 @@ +import asyncio +import enum +import logging +import threading +import time +from abc import ABCMeta +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +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 ByonoyBase(Driver, metaclass=ABCMeta): + """Shared HID communication logic for Byonoy plate readers.""" + + def __init__(self, pid: int, device_type: ByonoyDevice) -> None: + super().__init__() + self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) + self._background_thread: Optional[threading.Thread] = None + self._stop_background = threading.Event() + self._ping_interval = 1.0 + self._sending_pings = False + self._device_type = device_type + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + await self.io.setup() + logger.info("[Byonoy %s pid=0x%04X] connected", self._device_type.name, self.io.pid) + self._stop_background.clear() + self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True) + self._background_thread.start() + + async def stop(self) -> None: + self._stop_background.set() + if self._background_thread and self._background_thread.is_alive(): + self._background_thread.join(timeout=2.0) + await self.io.stop() + logger.info("[Byonoy %s pid=0x%04X] disconnected", self._device_type.name, self.io.pid) + + 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) + await self.io.write(command) + if not wait_for_response: + return None + + t0 = time.time() + while True: + if time.time() - t0 > 120: + logger.error( + "[Byonoy %s pid=0x%04X] timeout waiting for response to command 0x%04X after 120s", + self._device_type.name, + self.io.pid, + 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 + + def _background_ping_worker(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._ping_loop()) + except Exception: + logger.error("Background ping worker crashed", exc_info=True) + finally: + loop.close() + + async def _ping_loop(self) -> None: + while not self._stop_background.is_set(): + if self._sending_pings: + payload = Writer().u8(1).finish() + cmd = self._assemble_command( + report_id=0x0040, + payload=payload, + routing_info=b"\x00\x00", + ) + await self.io.write(cmd) + self._stop_background.wait(self._ping_interval) + + def _start_background_pings(self) -> None: + self._sending_pings = True + + def _stop_background_pings(self) -> None: + self._sending_pings = False diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py new file mode 100644 index 00000000000..6abf9e68c9d --- /dev/null +++ b/pylabrobot/byonoy/luminescence_96.py @@ -0,0 +1,340 @@ +import logging +import time +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.luminescence import ( + Luminescence, + LuminescenceBackend, + LuminescenceResult, +) +from pylabrobot.device import Device +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.serializer import SerializableMixin +from pylabrobot.utils.list import reshape_2d + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Backend +# --------------------------------------------------------------------------- + + +class ByonoyLuminescence96Backend(ByonoyBase, LuminescenceBackend): + """Backend for the Byonoy Luminescence 96 Automate plate reader.""" + + def __init__(self) -> None: + super().__init__(pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96) + + @dataclass + class LuminescenceParams(BackendParams): + """Byonoy Luminescence 96 parameters for luminescence reads. + + Args: + integration_time: Integration time in seconds. Default 2. + """ + + integration_time: float = 2 + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + """Read luminescence. + + Args: + plate: The plate being read. + wells: Wells to measure. + focal_height: Focal height in mm. + backend_params: Backend-specific parameters. + """ + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = ByonoyLuminescence96Backend.LuminescenceParams() + + integration_time = backend_params.integration_time + logger.info( + "[Byonoy L96 pid=0x%04X] reading luminescence: plate='%s', integration_time=%.1fs, wells=%d/%d", + self.io.pid, + plate.name, + integration_time, + len(wells), + plate.num_items, + ) + + 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 * 1000 * 1000)).raw_bytes(b"\xff" * 12).u8(0).u8(0).finish() + ) + await self.send_command( + report_id=0x0340, + payload=payload3, + wait_for_response=False, + ) + + t0 = time.time() + all_rows: List[Optional[float]] = [] + + while True: + if time.time() - t0 > 120: + logger.error("[Byonoy L96 pid=0x%04X] luminescence read timed out after 120s", self.io.pid) + raise TimeoutError("Reading luminescence data timed out after 2 minutes.") + + chunk = await self.io.read(64, timeout=30) + 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)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + all_rows.extend(row) + + if seq == seq_len - 1: + break + + hybrid_result: List[Optional[float]] = all_rows[96 * 0 : 96 * 1] + + return [ + LuminescenceResult( + data=reshape_2d(hybrid_result, (8, 12)), + temperature=None, + timestamp=time.time(), + ) + ] + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +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] = None, + 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("ByonoyBase 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( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, " + "respectively." + ) + + +# --------------------------------------------------------------------------- +# Device (reader unit — sits on top of a ByonoyLuminescenceBaseUnit) +# --------------------------------------------------------------------------- + + +class ByonoyLuminescence96(Resource, Device): + """Byonoy Luminescence 96 reader unit.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + preferred_pickup_location: Optional[Coordinate] = None, + ): + backend = ByonoyLuminescence96Backend() + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Byonoy L96 Reader Unit", + preferred_pickup_location=preferred_pickup_location, + ) + Device.__init__(self, driver=backend) + self.driver: ByonoyLuminescence96Backend = backend + self.luminescence = Luminescence(backend=backend) + self._capabilities = [self.luminescence] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.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/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py new file mode 100644 index 00000000000..ddf84ebd973 --- /dev/null +++ b/pylabrobot/capabilities/__init__.py @@ -0,0 +1 @@ +from .capability import Capability, CapabilityBackend, need_capability_ready diff --git a/pylabrobot/capabilities/arms/__init__.py b/pylabrobot/capabilities/arms/__init__.py new file mode 100644 index 00000000000..7afba7fa549 --- /dev/null +++ b/pylabrobot/capabilities/arms/__init__.py @@ -0,0 +1,5 @@ +from .arm import * +from .articulated_arm import * +from .backend import * +from .orientable_arm import * +from .standard import * diff --git a/pylabrobot/capabilities/arms/architecture.md b/pylabrobot/capabilities/arms/architecture.md new file mode 100644 index 00000000000..99ccf47d5ee --- /dev/null +++ b/pylabrobot/capabilities/arms/architecture.md @@ -0,0 +1,88 @@ +# Arms architecture + +## Frontend hierarchy (capabilities) + +``` +_BaseArm(Capability) + │ halt(), park(), get_gripper_location() + │ resource tracking (pick_up/drop state) + │ + └── GripperArm + │ open/close_gripper, is_gripper_closed + │ pick_up/drop/move at location + │ pick_up_resource(), drop_resource(), move_resource() (convenience) + │ + └── OrientableArm + Arm with rotation. E.g. Hamilton iSWAP, PreciseFlex. + pick_up/drop/move with direction parameter +``` + +Frontend mirrors backend hierarchy exactly. +Joint-space methods are backend-only (robot-specific), accessed via `arm.backend`. + +## Backend hierarchy (capability backends) + +``` +_BaseArmBackend(CapabilityBackend) + │ halt(), park(), get_gripper_location() + │ + ├── GripperArmBackend + │ open/close_gripper, is_gripper_closed + │ pick_up/drop/move at location (no rotation) + │ + ├── OrientableGripperArmBackend + │ pick_up/drop/move with direction (float degrees) + │ + └── ArticulatedGripperArmBackend + pick_up/drop/move with full Rotation +``` + +## Mixins (backend) + +- `HasJoints` — joint-space control: pick_up/drop/move at joint position, get_joint_position +- `CanFreedrive` — freedrive (manual guidance) mode + +## Concrete implementations + +| Device | Driver | Arm Backend | Frontend | +|--------|--------|-------------|----------| +| Hamilton STAR (iSWAP) | STARDriver (shared) | `iSWAP(OrientableGripperArmBackend)` | `OrientableArm` | +| Hamilton STAR (core) | STARDriver (shared) | `CoreGripper(GripperArmBackend)` | `Arm` | +| PreciseFlex 400 | `PreciseFlexDriver` | `PreciseFlexArmBackend(OrientableGripperArmBackend, HasJoints, CanFreedrive)` | `OrientableArm` | + +## Usage + +Arms are capabilities, not devices. They are owned by a Device: + +```python +class STAR(Device): + def __init__(self, ...): + driver = STARDriver(...) + super().__init__(driver=driver) + self.iswap = OrientableArm(backend=iSWAP(driver), reference_resource=deck) + self.core_gripper = GripperArm(backend=CoreGripper(driver), reference_resource=deck) + self._capabilities = [self.iswap, self.core_gripper] +``` + +A standalone arm (like PreciseFlex) is a Device with a single arm capability: + +```python +class PreciseFlex400(Device): + def __init__( + self, host, port=10100, has_rail=False, timeout=20, gripper_length=162.0, gripper_z_offset=0.0 + ): + driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + super().__init__(driver=driver) + backend = PreciseFlexArmBackend( + driver=driver, + has_rail=has_rail, + gripper_length=gripper_length, + gripper_z_offset=gripper_z_offset, + ) + self.arm = OrientableArm(backend=backend, reference_resource=self.reference) + self._capabilities = [self.arm] + +# Joint methods accessed via backend (robot-specific): +await pf.arm.backend.move_to_joint_position({1: 0, 2: 90, 3: 45}) +await pf.arm.backend.start_freedrive_mode(free_axes=[0]) +``` diff --git a/pylabrobot/capabilities/arms/arm.py b/pylabrobot/capabilities/arms/arm.py new file mode 100644 index 00000000000..07a4d973642 --- /dev/null +++ b/pylabrobot/capabilities/arms/arm.py @@ -0,0 +1,437 @@ +import logging +from dataclasses import dataclass +from typing import List, Literal, Optional, Tuple, Union + +from pylabrobot.capabilities.arms.backend import GripperArmBackend, _BaseArmBackend +from pylabrobot.capabilities.arms.standard import CartesianPose, GripDirection +from pylabrobot.capabilities.capability import BackendParams, Capability +from pylabrobot.legacy.tilting.tilter import Tilter +from pylabrobot.resources import ( + Coordinate, + Lid, + Plate, + PlateAdapter, + Resource, + ResourceHolder, + ResourceStack, + Trash, +) +from pylabrobot.resources.rotation import Rotation + +logger = logging.getLogger(__name__) + +GripOrientation = Union[GripDirection, float] + + +@dataclass +class _PickedUpState: + resource: Resource + offset: Coordinate + pickup_distance_from_bottom: float + resource_width: float + rotation: Rotation = Rotation() + + +class _BaseArm(Capability): + """Base class for all arm types. Not instantiated directly.""" + + def __init__(self, backend, reference_resource: Resource): + super().__init__(backend=backend) + self.backend: _BaseArmBackend = backend + self._reference_resource = reference_resource + self._picked_up: Optional[_PickedUpState] = None + self._holding_resource_width: Optional[float] = None + + async def _on_setup(self, backend_params: Optional[BackendParams] = None, **backend_kwargs): + await super()._on_setup(backend_params=backend_params, **backend_kwargs) + self._picked_up = None + self._holding_resource_width = None + + async def _on_stop(self): + await super()._on_stop() + self._picked_up = None + self._holding_resource_width = None + + def _state_updated(self): + pass + + @property + def holding(self) -> bool: + return self._holding_resource_width is not None + + def get_picked_up_resource(self) -> Optional[Resource]: + if self._picked_up is not None: + return self._picked_up.resource + return None + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop any ongoing movement of the arm.""" + return await self.backend.halt(backend_params=backend_params) + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the arm to its default position.""" + return await self.backend.park(backend_params=backend_params) + + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> CartesianPose: + """Get the current location and rotation of the gripper.""" + return await self.backend.request_gripper_location(backend_params=backend_params) + + # -- holding state ----------------------------------------------------------- + + def _begin_holding(self, resource_width: float): + if self.holding: + name = self._picked_up.resource.name if self._picked_up else "" + raise RuntimeError(f"Already holding{' ' + name if name else ''}") + self._holding_resource_width = resource_width + + def _end_holding(self): + self._picked_up = None + self._holding_resource_width = None + + # -- coordinate computation ------------------------------------------------- + + def _pickup_location( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_bottom: float, + ) -> Coordinate: + assert self._reference_resource is not None + center = resource.center().rotated(resource.get_absolute_rotation()) + if resource.is_in_subtree_of(self._reference_resource): + loc = resource.get_location_wrt(self._reference_resource, "l", "f", "b") + center + offset + else: + loc = center + offset + return Coordinate(loc.x, loc.y, loc.z + pickup_distance_from_bottom) + + def _destination_location( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + resource_rotation_wrt_destination_wrt_local: float, + ) -> Coordinate: + assert self._reference_resource is not None + if isinstance(destination, ResourceStack): + assert destination.direction == "z" + return destination.get_location_wrt( + self._reference_resource + ) + destination.get_new_child_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + elif isinstance(destination, Coordinate): + return destination + elif isinstance(destination, ResourceHolder): + if destination.resource is not None and destination.resource is not resource: + raise RuntimeError("Destination already has a plate") + child_wrt_parent = destination.get_default_child_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + return destination.get_location_wrt(self._reference_resource) + child_wrt_parent + elif isinstance(destination, PlateAdapter): + if not isinstance(resource, Plate): + raise ValueError("Only plates can be moved to a PlateAdapter") + adjusted_plate_anchor = destination.compute_plate_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + return destination.get_location_wrt(self._reference_resource) + adjusted_plate_anchor + elif isinstance(destination, Plate) and isinstance(resource, Lid): + plate_location = destination.get_location_wrt(self._reference_resource) + child_wrt_parent = destination.get_lid_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + return plate_location + child_wrt_parent + else: + return destination.get_location_wrt(self._reference_resource) + + def _compute_end_effector_location( + self, + resource: Resource, + to_location: Coordinate, + offset: Coordinate, + pickup_distance_from_bottom: float, + rotation_applied_by_move: float, + ) -> Coordinate: + center = resource.center().rotated( + Rotation(z=resource.get_absolute_rotation().z + rotation_applied_by_move) + ) + loc = to_location + center + offset + return Coordinate( + loc.x, + loc.y, + loc.z + pickup_distance_from_bottom, + ) + + def _move_location( + self, + resource: Resource, + to: Coordinate, + offset: Coordinate, + pickup_distance_from_bottom: float, + ) -> Coordinate: + return ( + to + resource.get_anchor("c", "c", "b") + Coordinate(z=pickup_distance_from_bottom) + offset + ) + + def _resolve_pickup_distance( + self, resource: Resource, pickup_distance_from_bottom: Optional[float] + ) -> float: + if pickup_distance_from_bottom is not None: + return pickup_distance_from_bottom + if resource.preferred_pickup_location is not None: + logger.debug( + "Using preferred pickup location for resource %s as pickup_distance_from_bottom was " + "not specified.", + resource.name, + ) + return resource.preferred_pickup_location.z + logger.debug( + "No preferred pickup location for resource %s. Using default pickup distance of 5mm " + "from top (= size_z - 5).", + resource.name, + ) + return resource.get_size_z() - 5.0 + + def _assign_after_drop( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + ) -> None: + assert self._reference_resource is not None + resource.unassign() + if isinstance(destination, Coordinate): + destination -= self._reference_resource.location + self._reference_resource.assign_child_resource(resource, location=destination) + elif isinstance(destination, ResourceHolder): + destination.assign_child_resource(resource) + elif isinstance(destination, ResourceStack): + if destination.direction != "z": + raise ValueError("Only ResourceStacks with direction 'z' are currently supported") + destination.assign_child_resource(resource) + elif isinstance(destination, Tilter): + destination.assign_child_resource(resource, location=destination.child_location) + elif isinstance(destination, PlateAdapter): + if not isinstance(resource, Plate): + raise ValueError("Only plates can be moved to a PlateAdapter") + destination.assign_child_resource( + resource, location=destination.compute_plate_location(resource) + ) + elif isinstance(destination, Plate) and isinstance(resource, Lid): + destination.assign_child_resource(resource) + elif isinstance(destination, Trash): + pass + else: + destination.assign_child_resource( + resource, location=destination.get_location_wrt(self._reference_resource) + ) + + def _compute_drop( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate, + pickup_distance_from_bottom: float, + rotation_applied_by_move: float = 0, + ) -> Tuple[Coordinate, float]: + resource_absolute_rotation_after_move = ( + resource.get_absolute_rotation().z + rotation_applied_by_move + ) + dest_rotation = ( + destination.get_absolute_rotation().z if not isinstance(destination, Coordinate) else 0 + ) + resource_rotation_wrt_destination = resource_absolute_rotation_after_move - dest_rotation + resource_rotation_wrt_destination_wrt_local = ( + resource_rotation_wrt_destination - resource.rotation.z + ) + + if isinstance(destination, ResourceStack): + if resource_rotation_wrt_destination % 180 != 0: + raise ValueError( + "Resource rotation wrt ResourceStack must be a multiple of 180 degrees, " + f"got {resource_rotation_wrt_destination} degrees" + ) + + to_location = self._destination_location( + resource, destination, resource_rotation_wrt_destination_wrt_local + ) + location = self._compute_end_effector_location( + resource, to_location, offset, pickup_distance_from_bottom, rotation_applied_by_move + ) + return location, resource_rotation_wrt_destination + + def _prepare_pickup( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_bottom: Optional[float], + ) -> Tuple[Coordinate, float]: + pickup_distance_from_bottom = self._resolve_pickup_distance( + resource, pickup_distance_from_bottom + ) + assert resource.get_absolute_rotation().x == 0 and resource.get_absolute_rotation().y == 0 + assert resource.get_absolute_rotation().z % 90 == 0 + location = self._pickup_location(resource, offset, pickup_distance_from_bottom) + return location, pickup_distance_from_bottom + + def _prepare_drop( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + ) -> Resource: + if self._picked_up is None: + raise RuntimeError("No resource picked up") + if isinstance(destination, Resource): + destination.check_can_drop_resource_here(self._picked_up.resource) + return self._picked_up.resource + + def _finalize_drop( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + resource_rotation_wrt_destination: float, + ) -> None: + self._end_holding() + self._state_updated() + resource.rotate(z=resource_rotation_wrt_destination - resource.rotation.z) + self._assign_after_drop(resource, destination) + + +class GripperArm(_BaseArm): + """A gripper arm without rotation capability. E.g. Hamilton core grippers.""" + + def __init__( + self, + backend: GripperArmBackend, + reference_resource: Resource, + grip_axis: Literal["x", "y"] = "x", + ): + super().__init__(backend=backend, reference_resource=reference_resource) + self.backend: GripperArmBackend = backend + self._grip_axis = grip_axis + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + return await self.backend.open_gripper( + gripper_width=gripper_width, backend_params=backend_params + ) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + return await self.backend.close_gripper( + gripper_width=gripper_width, backend_params=backend_params + ) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return await self.backend.is_gripper_closed(backend_params=backend_params) + + def _resource_width(self, resource: Resource) -> float: + if self._grip_axis == "y": + return resource.get_absolute_size_y() + return resource.get_absolute_size_x() + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ): + if self.holding: + name = self._picked_up.resource.name if self._picked_up else "" + raise RuntimeError(f"Already holding{' ' + name if name else ''}") + await self.backend.pick_up_at_location( + location=location, resource_width=resource_width, backend_params=backend_params + ) + self._holding_resource_width = resource_width + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + backend_params: Optional[BackendParams] = None, + ): + location, pickup_distance_from_bottom = self._prepare_pickup( + resource, offset, pickup_distance_from_bottom + ) + resource_width = self._resource_width(resource) + await self.pick_up_at_location(location, resource_width, backend_params) + self._picked_up = _PickedUpState( + resource=resource, + offset=offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + resource_width=resource_width, + ) + self._state_updated() + + async def drop_at_location( + self, + location: Coordinate, + backend_params: Optional[BackendParams] = None, + ): + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location=location, resource_width=self._holding_resource_width, backend_params=backend_params + ) + self._end_holding() + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + resource = self._prepare_drop(destination) + if self._picked_up is None: + raise RuntimeError("No resource picked up") + location, rotation = self._compute_drop( + resource=resource, + destination=destination, + offset=offset, + pickup_distance_from_bottom=self._picked_up.pickup_distance_from_bottom, + ) + await self.drop_at_location(location, backend_params) + self._finalize_drop(resource, destination, rotation) + + async def move_to_location( + self, location: Coordinate, backend_params: Optional[BackendParams] = None + ): + await self.backend.move_to_location(location=location, backend_params=backend_params) + + async def move_picked_up_resource( + self, + to: Coordinate, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + if self._picked_up is None: + raise RuntimeError("No resource picked up") + location = self._move_location( + self._picked_up.resource, to, offset, self._picked_up.pickup_distance_from_bottom + ) + await self.backend.move_to_location(location=location, backend_params=backend_params) + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + pickup_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + backend_params=pickup_backend_params, + ) + for loc in intermediate_locations or []: + await self.move_picked_up_resource(to=loc) + await self.drop_resource( + destination=to, offset=destination_offset, backend_params=drop_backend_params + ) diff --git a/pylabrobot/capabilities/arms/arm_tests.py b/pylabrobot/capabilities/arms/arm_tests.py new file mode 100644 index 00000000000..56c65485982 --- /dev/null +++ b/pylabrobot/capabilities/arms/arm_tests.py @@ -0,0 +1,181 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.capabilities.arms.arm import GripperArm +from pylabrobot.capabilities.arms.backend import ( + GripperArmBackend, + OrientableGripperArmBackend, +) +from pylabrobot.capabilities.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.arms.standard import GripDirection +from pylabrobot.resources import Coordinate, Resource, ResourceHolder + + +def _assert_location(test, call, x, y, z, places=1): + """Assert the location kwarg of a mock call matches expected coordinates.""" + loc = call.kwargs["location"] + test.assertAlmostEqual(loc.x, x, places=places) + test.assertAlmostEqual(loc.y, y, places=places) + test.assertAlmostEqual(loc.z, z, places=places) + + +def _make_deck_with_sites(): + """Create a fictional deck with two sites and a plate. + + Deck: 1000x1000x0 at origin. + Site A at (100, 100, 50), site B at (100, 300, 50). + Plate: 120x80x10 assigned to site A. + """ + deck = Resource("deck", size_x=1000, size_y=1000, size_z=0) + + site_a = ResourceHolder("site_a", size_x=130, size_y=90, size_z=0) + deck.assign_child_resource(site_a, location=Coordinate(100, 100, 50)) + + site_b = ResourceHolder("site_b", size_x=130, size_y=90, size_z=0) + deck.assign_child_resource(site_b, location=Coordinate(100, 300, 50)) + + plate = Resource("plate", size_x=120, size_y=80, size_z=10) + site_a.assign_child_resource(plate, location=Coordinate(5, 5, 0)) + + return deck, site_a, site_b, plate + + +class TestArm(unittest.IsolatedAsyncioTestCase): + """Test Arm (ArmBackend, no rotation). E.g. Hamilton core grippers.""" + + async def asyncSetUp(self): + self.mock_backend = MagicMock(spec=GripperArmBackend) + for method_name in [ + "pick_up_at_location", + "drop_at_location", + "move_to_location", + "open_gripper", + "close_gripper", + "is_gripper_closed", + "halt", + "park", + ]: + setattr(self.mock_backend, method_name, AsyncMock()) + + self.deck, self.site_a, self.site_b, self.plate = _make_deck_with_sites() + self.arm = GripperArm(backend=self.mock_backend, reference_resource=self.deck) + + async def test_pick_up_resource(self): + # plate at site_a(100,100,50) + child_loc(5,5,0), center_xy=(60,40), size_z=10 + # pickup_distance_from_bottom=8 → z = 50 + 8 = 58 + await self.arm.pick_up_resource(self.plate, pickup_distance_from_bottom=8) + call = self.mock_backend.pick_up_at_location.call_args + _assert_location(self, call, 165, 145, 58) + # default grip_axis="x" → resource_width is X size = 120 + self.assertAlmostEqual(call.kwargs["resource_width"], 120) + + async def test_drop_resource(self): + await self.arm.pick_up_resource(self.plate, pickup_distance_from_bottom=8) + await self.arm.drop_resource(self.site_b) + call = self.mock_backend.drop_at_location.call_args + # site_b(100,300,50) + default_child_loc(0,0,0), size_z=10 + # pickup_distance_from_bottom=8 → z = 50 + 8 = 58 + _assert_location(self, call, 160, 340, 58) + self.assertEqual(self.plate.parent.name, "site_b") + + async def test_pick_up_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0) + self.mock_backend.pick_up_at_location.assert_called_once_with( + location=location, resource_width=80.0, backend_params=None + ) + + async def test_drop_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0) + await self.arm.drop_at_location(location) + self.mock_backend.drop_at_location.assert_called_once_with( + location=location, resource_width=80.0, backend_params=None + ) + + async def test_move_to_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.move_to_location(location) + self.mock_backend.move_to_location.assert_called_once_with( + location=location, backend_params=None + ) + + async def test_open_gripper(self): + await self.arm.open_gripper(gripper_width=50.0) + self.mock_backend.open_gripper.assert_called_once_with(gripper_width=50.0, backend_params=None) + + async def test_halt(self): + await self.arm.halt() + self.mock_backend.halt.assert_called_once() + + async def test_park(self): + await self.arm.park() + self.mock_backend.park.assert_called_once() + + async def test_grip_axis_y(self): + """With grip_axis='y', resource_width should be the Y size.""" + arm_y = GripperArm(backend=self.mock_backend, reference_resource=self.deck, grip_axis="y") + await arm_y.pick_up_resource(self.plate, pickup_distance_from_bottom=8) + call = self.mock_backend.pick_up_at_location.call_args + # plate size_y=80 + self.assertAlmostEqual(call.kwargs["resource_width"], 80) + + +class TestOrientableArm(unittest.IsolatedAsyncioTestCase): + """Test OrientableArm coordinate computation with fictional resources.""" + + async def asyncSetUp(self): + self.mock_backend = MagicMock(spec=OrientableGripperArmBackend) + for method_name in [ + "pick_up_at_location", + "drop_at_location", + "move_to_location", + ]: + setattr(self.mock_backend, method_name, AsyncMock()) + + self.deck, self.site_a, self.site_b, self.plate = _make_deck_with_sites() + self.arm = OrientableArm(backend=self.mock_backend, reference_resource=self.deck) + + async def test_pick_up_front(self): + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_bottom=8, direction=GripDirection.FRONT + ) + call = self.mock_backend.pick_up_at_location.call_args + _assert_location(self, call, 165, 145, 58) + self.assertAlmostEqual(call.kwargs["direction"], 0.0) + # FRONT → X width = 120 + self.assertAlmostEqual(call.kwargs["resource_width"], 120) + + async def test_pick_up_right(self): + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_bottom=8, direction=GripDirection.RIGHT + ) + call = self.mock_backend.pick_up_at_location.call_args + self.assertAlmostEqual(call.kwargs["direction"], 90.0) + # RIGHT → Y width = 80 + self.assertAlmostEqual(call.kwargs["resource_width"], 80) + + async def test_drop_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0, direction=0.0) + await self.arm.drop_at_location(location, direction=180.0) + self.mock_backend.drop_at_location.assert_called_once_with( + location=location, direction=180.0, resource_width=80.0, backend_params=None + ) + + async def test_move_to_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.move_to_location(location, direction=90.0) + self.mock_backend.move_to_location.assert_called_once_with( + location=location, direction=90.0, backend_params=None + ) + + async def test_move_plate(self): + """Pick from site_a, drop at site_b.""" + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_bottom=8, direction=GripDirection.FRONT + ) + await self.arm.drop_resource(self.site_b, direction=GripDirection.FRONT) + drop_call = self.mock_backend.drop_at_location.call_args + _assert_location(self, drop_call, 160, 340, 58) + self.assertEqual(self.plate.parent.name, "site_b") diff --git a/pylabrobot/capabilities/arms/articulated_arm.py b/pylabrobot/capabilities/arms/articulated_arm.py new file mode 100644 index 00000000000..752c31bb8eb --- /dev/null +++ b/pylabrobot/capabilities/arms/articulated_arm.py @@ -0,0 +1,170 @@ +from typing import List, Optional, Union + +from pylabrobot.capabilities.arms.arm import _BaseArm, _PickedUpState +from pylabrobot.capabilities.arms.backend import ArticulatedGripperArmBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate, Resource, ResourceHolder, ResourceStack +from pylabrobot.resources.rotation import Rotation + + +class ArticulatedArm(_BaseArm): + """An arm with full 3D rotation capability. E.g. a 6-axis robot arm.""" + + def __init__(self, backend: ArticulatedGripperArmBackend, reference_resource: Resource): + super().__init__(backend=backend, reference_resource=reference_resource) + self.backend: ArticulatedGripperArmBackend = backend # type: ignore[assignment] + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.open_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.close_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return await self.backend.is_gripper_closed(backend_params=backend_params) + + @staticmethod + def _resource_width_for_rotation(resource: Resource, rotation: Rotation) -> float: + if rotation.z % 180 == 0: + return resource.get_absolute_size_x() + else: + return resource.get_absolute_size_y() + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + if self.holding: + name = self._picked_up.resource.name if self._picked_up else "" + raise RuntimeError(f"Already holding{' ' + name if name else ''}") + await self.backend.pick_up_at_location( + location=location, + rotation=rotation, + resource_width=resource_width, + backend_params=backend_params, + ) + self._holding_resource_width = resource_width + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + location, pickup_distance_from_bottom = self._prepare_pickup( + resource, offset, pickup_distance_from_bottom + ) + resource_width = self._resource_width_for_rotation(resource, rotation) + await self.pick_up_at_location(location, resource_width, rotation, backend_params) + self._picked_up = _PickedUpState( + resource=resource, + offset=offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + resource_width=resource_width, + rotation=rotation, + ) + self._state_updated() + + async def drop_at_location( + self, + location: Coordinate, + rotation: Rotation, + backend_params: Optional[BackendParams] = None, + ): + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location=location, + rotation=rotation, + resource_width=self._holding_resource_width, + backend_params=backend_params, + ) + self._end_holding() + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + resource = self._prepare_drop(destination) + if self._picked_up is None: + raise RuntimeError("No resource picked up") + drop_z = rotation.z + rotation_applied_by_move = (drop_z - self._picked_up.rotation.z) % 360 + location, final_rotation = self._compute_drop( + resource=resource, + destination=destination, + offset=offset, + pickup_distance_from_bottom=self._picked_up.pickup_distance_from_bottom, + rotation_applied_by_move=rotation_applied_by_move, + ) + await self.drop_at_location(location, rotation, backend_params) + self._finalize_drop(resource, destination, final_rotation) + + async def move_to_location( + self, + location: Coordinate, + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + await self.backend.move_to_location( + location=location, + rotation=rotation, + backend_params=backend_params, + ) + + async def move_picked_up_resource( + self, + to: Coordinate, + rotation: Rotation, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + if self._picked_up is None: + raise RuntimeError("No resource picked up") + location = self._move_location( + self._picked_up.resource, to, offset, self._picked_up.pickup_distance_from_bottom + ) + await self.backend.move_to_location( + location=location, rotation=rotation, backend_params=backend_params + ) + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + pickup_rotation: Rotation = Rotation(), + drop_rotation: Rotation = Rotation(), + pickup_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + rotation=pickup_rotation, + backend_params=pickup_backend_params, + ) + for loc in intermediate_locations or []: + await self.move_picked_up_resource(to=loc, rotation=drop_rotation) + await self.drop_resource( + destination=to, + offset=destination_offset, + rotation=drop_rotation, + backend_params=drop_backend_params, + ) diff --git a/pylabrobot/capabilities/arms/backend.py b/pylabrobot/capabilities/arms/backend.py new file mode 100644 index 00000000000..a05c7cdfa38 --- /dev/null +++ b/pylabrobot/capabilities/arms/backend.py @@ -0,0 +1,215 @@ +from abc import ABCMeta, abstractmethod +from typing import List, Optional + +from pylabrobot.capabilities.arms.standard import CartesianPose, JointPose +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation + +# ArmBackend: +# - pick_up_at_location +# - drop_at_location +# - move_to_location +# - request_gripper_location +# - is_holding_resource + +# CanGrip +# - open_gripper +# - close_gripper +# - is_gripper_closed + +# CanSuction +# - start_suction +# - stop_suction + +# CanFreedrive +# - start_freedrive_mode +# - stop_freedrive_mode + +# Joints +# - pick_up_at_joint_position +# - drop_at_joint_position +# - request_joint_position + + +class CanFreedrive(metaclass=ABCMeta): + """Mixin for arms that support freedrive (manual guidance) mode.""" + + @abstractmethod + async def start_freedrive_mode( + self, free_axes: List[int], backend_params: Optional[BackendParams] = None + ) -> None: + """Enter freedrive mode, allowing manual movement of the specified joints. + + Args: + free_axes: List of joint indices to free. Use [0] for all axes. + """ + + @abstractmethod + async def stop_freedrive_mode(self, backend_params: Optional[BackendParams] = None) -> None: + """Exit freedrive mode.""" + + +class HasJoints(metaclass=ABCMeta): + """Mixin for arms that can be controlled in joint space.""" + + @abstractmethod + async def pick_up_at_joint_position( + self, + position: JointPose, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified joint position.""" + + @abstractmethod + async def drop_at_joint_position( + self, + position: JointPose, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified joint position.""" + + @abstractmethod + async def move_to_joint_position( + self, position: JointPose, backend_params: Optional[BackendParams] = None + ) -> None: + """Move the arm to the specified joint position.""" + + @abstractmethod + async def request_joint_position( + self, backend_params: Optional[BackendParams] = None + ) -> JointPose: + """Get the current position of the arm in joint space.""" + + +Smokes = HasJoints + + +class CanGrip(metaclass=ABCMeta): + """Mixin for arms that have a gripper.""" + + @abstractmethod + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the gripper to the specified width.""" + + @abstractmethod + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Close the gripper to the specified width.""" + + @abstractmethod + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Check if the gripper is currently closed.""" + + +class _BaseArmBackend(CapabilityBackend, metaclass=ABCMeta): + @abstractmethod + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop any ongoing movement of the arm.""" + + @abstractmethod + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the arm to its default position.""" + + @abstractmethod + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> CartesianPose: + """Get the current location and rotation of the gripper.""" + + +class GripperArmBackend(_BaseArmBackend, CanGrip, metaclass=ABCMeta): + """Backend for a simple arm (no rotation capability). E.g. Hamilton core grippers.""" + + @abstractmethod + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified location.""" + + @abstractmethod + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified location.""" + + @abstractmethod + async def move_to_location( + self, location: Coordinate, backend_params: Optional[BackendParams] = None + ) -> None: + """Move the held object to the specified location.""" + + +class OrientableGripperArmBackend(_BaseArmBackend, CanGrip, metaclass=ABCMeta): + """Backend for an arm with rotation capability. E.g. Hamilton iSwap.""" + + @abstractmethod + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified location with rotation.""" + + @abstractmethod + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified location with rotation.""" + + @abstractmethod + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the held object to the specified location with rotation.""" + + +class ArticulatedGripperArmBackend(_BaseArmBackend, CanGrip, metaclass=ABCMeta): + @abstractmethod + async def pick_up_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified location with rotation.""" + + @abstractmethod + async def drop_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified location with rotation.""" + + @abstractmethod + async def move_to_location( + self, + location: Coordinate, + rotation: Rotation, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the held object to the specified location with rotation.""" diff --git a/pylabrobot/capabilities/arms/orientable_arm.py b/pylabrobot/capabilities/arms/orientable_arm.py new file mode 100644 index 00000000000..848364c5fb5 --- /dev/null +++ b/pylabrobot/capabilities/arms/orientable_arm.py @@ -0,0 +1,191 @@ +from typing import List, Optional, Union + +from pylabrobot.capabilities.arms.arm import GripOrientation, _BaseArm, _PickedUpState +from pylabrobot.capabilities.arms.backend import OrientableGripperArmBackend +from pylabrobot.capabilities.arms.standard import GripDirection +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate, Resource, ResourceHolder, ResourceStack +from pylabrobot.resources.rotation import Rotation + +_GRIP_DIRECTION_TO_DEGREES = { + GripDirection.FRONT: 0.0, + GripDirection.RIGHT: 90.0, + GripDirection.BACK: 180.0, + GripDirection.LEFT: 270.0, +} + + +def _resolve_direction(direction: GripOrientation) -> float: + if isinstance(direction, GripDirection): + return _GRIP_DIRECTION_TO_DEGREES[direction] + return direction + + +class OrientableArm(_BaseArm): + """An arm with rotation capability. E.g. Hamilton iSWAP.""" + + def __init__(self, backend: OrientableGripperArmBackend, reference_resource: Resource): + super().__init__(backend=backend, reference_resource=reference_resource) + self.backend: OrientableGripperArmBackend = backend # type: ignore[assignment] + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.open_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.close_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return await self.backend.is_gripper_closed(backend_params=backend_params) + + @staticmethod + def _resource_width_for_direction(resource: Resource, direction: float) -> float: + # TODO: resource rotation is not taken into account here. + if direction % 180 == 0: + return resource.get_absolute_size_x() + else: + return resource.get_absolute_size_y() + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + direction: GripOrientation = 0.0, + backend_params: Optional[BackendParams] = None, + ): + if self.holding: + name = self._picked_up.resource.name if self._picked_up else "" + raise RuntimeError(f"Already holding{' ' + name if name else ''}") + dir_degrees = _resolve_direction(direction) + await self.backend.pick_up_at_location( + location=location, + direction=dir_degrees, + resource_width=resource_width, + backend_params=backend_params, + ) + self._holding_resource_width = resource_width + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + direction: GripOrientation = GripDirection.FRONT, + backend_params: Optional[BackendParams] = None, + ): + location, pickup_distance_from_bottom = self._prepare_pickup( + resource, offset, pickup_distance_from_bottom + ) + dir_degrees = _resolve_direction(direction) + resource_width = self._resource_width_for_direction(resource, dir_degrees) + # if gripper: + await self.pick_up_at_location(location, resource_width, dir_degrees, backend_params) + # if suction: + # TODO: + self._picked_up = _PickedUpState( + resource=resource, + offset=offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + resource_width=resource_width, + rotation=Rotation(z=dir_degrees), + ) + self._state_updated() + + async def drop_at_location( + self, + location: Coordinate, + direction: GripOrientation, + backend_params: Optional[BackendParams] = None, + ): + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location=location, + direction=_resolve_direction(direction), + resource_width=self._holding_resource_width, + backend_params=backend_params, + ) + self._end_holding() + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + direction: GripOrientation = GripDirection.FRONT, + backend_params: Optional[BackendParams] = None, + ): + resource = self._prepare_drop(destination) + if self._picked_up is None: + raise RuntimeError("No resource picked up") + drop_dir = _resolve_direction(direction) + rotation_applied_by_move = (drop_dir - self._picked_up.rotation.z) % 360 + location, rotation = self._compute_drop( + resource=resource, + destination=destination, + offset=offset, + pickup_distance_from_bottom=self._picked_up.pickup_distance_from_bottom, + rotation_applied_by_move=rotation_applied_by_move, + ) + await self.drop_at_location(location, drop_dir, backend_params) + self._finalize_drop(resource, destination, rotation) + + async def move_to_location( + self, + location: Coordinate, + direction: GripOrientation = 0.0, + backend_params: Optional[BackendParams] = None, + ): + await self.backend.move_to_location( + location=location, + direction=_resolve_direction(direction), + backend_params=backend_params, + ) + + async def move_picked_up_resource( + self, + to: Coordinate, + direction: GripOrientation, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + if self._picked_up is None: + raise RuntimeError("No resource picked up") + dir_degrees = _resolve_direction(direction) + location = self._move_location( + self._picked_up.resource, to, offset, self._picked_up.pickup_distance_from_bottom + ) + await self.backend.move_to_location( + location=location, direction=dir_degrees, backend_params=backend_params + ) + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + pickup_direction: GripOrientation = GripDirection.FRONT, + drop_direction: GripOrientation = GripDirection.FRONT, + pickup_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + direction=pickup_direction, + backend_params=pickup_backend_params, + ) + for loc in intermediate_locations or []: + await self.move_picked_up_resource(to=loc, direction=drop_direction) + await self.drop_resource( + destination=to, + offset=destination_offset, + direction=drop_direction, + backend_params=drop_backend_params, + ) diff --git a/pylabrobot/capabilities/arms/standard.py b/pylabrobot/capabilities/arms/standard.py new file mode 100644 index 00000000000..4b3d983d60a --- /dev/null +++ b/pylabrobot/capabilities/arms/standard.py @@ -0,0 +1,44 @@ +import enum +from dataclasses import dataclass +from typing import Dict + +from pylabrobot.resources import Coordinate, Rotation + +JointPose = Dict[int, float] + + +@dataclass +class CartesianPose: + """Location and rotation of the gripper. Subclass for robot-specific fields.""" + + location: Coordinate + rotation: Rotation + + +class GripDirection(enum.Enum): + FRONT = enum.auto() + BACK = enum.auto() + LEFT = enum.auto() + RIGHT = enum.auto() + + +@dataclass(frozen=True) +class ResourcePickup: + location: Coordinate # center of end effector when gripping the resource + rotation: Rotation # rotation of end effector when gripping the resource + resource_width: float + + +@dataclass(frozen=True) +class ResourceMove: + """Moving a resource that was already picked up.""" + + location: Coordinate # center of end effector when moving the resource + rotation: Rotation # rotation of end effector when moving the resource + + +@dataclass(frozen=True) +class ResourceDrop: + location: Coordinate # center of end effector when dropping the resource + rotation: Rotation # rotation of end effector when dropping the resource + resource_width: float diff --git a/pylabrobot/capabilities/automated_retrieval/__init__.py b/pylabrobot/capabilities/automated_retrieval/__init__.py new file mode 100644 index 00000000000..b4bd3544909 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/__init__.py @@ -0,0 +1,2 @@ +from .automated_retrieval import AutomatedRetrieval +from .backend import AutomatedRetrievalBackend diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py new file mode 100644 index 00000000000..5f7ccd45c57 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -0,0 +1,28 @@ +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.resources import Plate, PlateHolder + +from .backend import AutomatedRetrievalBackend + + +class AutomatedRetrieval(Capability): + """Automated plate retrieval/storage capability. + + See :doc:`/user_guide/capabilities/automated-retrieval` for a walkthrough. + """ + + def __init__(self, backend: AutomatedRetrievalBackend): + super().__init__(backend=backend) + self.backend: AutomatedRetrievalBackend = backend + + @need_capability_ready + async def fetch_plate_to_loading_tray(self, plate: Plate): + """Retrieve a plate from storage and place it on the loading tray.""" + await self.backend.fetch_plate_to_loading_tray(plate) + + @need_capability_ready + async def store_plate(self, plate: Plate, site: PlateHolder): + """Store a plate from the loading tray into the given site.""" + await self.backend.store_plate(plate, site) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/automated_retrieval/backend.py b/pylabrobot/capabilities/automated_retrieval/backend.py new file mode 100644 index 00000000000..5ad8179d946 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/backend.py @@ -0,0 +1,16 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.resources import Plate, PlateHolder + + +class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for automated plate retrieval/storage devices.""" + + @abstractmethod + async def fetch_plate_to_loading_tray(self, plate: Plate): + """Retrieve a plate from storage and place it on the loading tray.""" + + @abstractmethod + async def store_plate(self, plate: Plate, site: PlateHolder): + """Store a plate from the loading tray into the given site.""" diff --git a/pylabrobot/capabilities/automated_retrieval/chatterbox.py b/pylabrobot/capabilities/automated_retrieval/chatterbox.py new file mode 100644 index 00000000000..d1ac1c8d463 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from pylabrobot.resources.carrier import PlateHolder +from pylabrobot.resources.plate import Plate + +from .backend import AutomatedRetrievalBackend + +logger = logging.getLogger(__name__) + + +class AutomatedRetrievalChatterboxBackend(AutomatedRetrievalBackend): + """Chatterbox backend for device-free testing.""" + + async def fetch_plate_to_loading_tray(self, plate: Plate): + logger.info("Fetching plate %s to loading tray.", plate.name) + + async def store_plate(self, plate: Plate, site: PlateHolder): + logger.info("Storing plate %s at site %s.", plate.name, site.name) diff --git a/pylabrobot/capabilities/barcode_scanning/__init__.py b/pylabrobot/capabilities/barcode_scanning/__init__.py new file mode 100644 index 00000000000..498af9e43da --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/__init__.py @@ -0,0 +1,2 @@ +from .backend import BarcodeScannerBackend, BarcodeScannerError +from .barcode_scanning import BarcodeScanner diff --git a/pylabrobot/capabilities/barcode_scanning/backend.py b/pylabrobot/capabilities/barcode_scanning/backend.py new file mode 100644 index 00000000000..ea90f332dad --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/backend.py @@ -0,0 +1,30 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.resources.barcode import Barcode + + +class BarcodeScannerError(Exception): + """Error raised by a barcode scanner backend.""" + + +class BarcodeScannerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for barcode scanning devices.""" + + @abstractmethod + async def scan_barcode(self, read_time: Optional[float] = None) -> Optional[Barcode]: + """Scan a barcode and return its value, or ``None`` if no barcode is + decoded within the read window. + + Args: + read_time: Optional read-window in seconds. ``None`` means use whatever + default the underlying device is currently configured with. Backends + for devices that don't expose a configurable window may ignore it. + + Returns: + The decoded :class:`Barcode`, or ``None`` if the read window elapsed + with no successful decode. Backends still raise + :class:`BarcodeScannerError` for hardware faults (reader off, comms + failure) — ``None`` is reserved for the "nothing seen" case. + """ diff --git a/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py new file mode 100644 index 00000000000..9f6b836c250 --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py @@ -0,0 +1,36 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.resources.barcode import Barcode + +from .backend import BarcodeScannerBackend + + +class BarcodeScanner(Capability): + """Barcode scanning capability. + + See :doc:`/user_guide/capabilities/barcode-scanning` for a walkthrough. + """ + + def __init__(self, backend: BarcodeScannerBackend): + super().__init__(backend=backend) + self.backend: BarcodeScannerBackend = backend + + @need_capability_ready + async def scan(self, read_time: Optional[float] = None) -> Optional[Barcode]: + """Scan a barcode and return its value, or ``None`` if nothing decoded + within the read window. + + Args: + read_time: Optional read-window in seconds for this scan. If omitted, + the backend uses the device's current default. Backends for scanners + without a configurable window may ignore this argument. + + Returns: + The decoded :class:`Barcode`, or ``None`` if the read window elapsed + without a successful decode. + """ + return await self.backend.scan_barcode(read_time=read_time) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/barcode_scanning/chatterbox.py b/pylabrobot/capabilities/barcode_scanning/chatterbox.py new file mode 100644 index 00000000000..f5cfae77bc2 --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/chatterbox.py @@ -0,0 +1,21 @@ +import logging +from typing import Optional + +from pylabrobot.resources.barcode import Barcode + +from .backend import BarcodeScannerBackend + +logger = logging.getLogger(__name__) + + +class BarcodeScannerChatterboxBackend(BarcodeScannerBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self, barcode: str = "CHATTERBOX-001"): + self.barcode = barcode + + async def scan_barcode(self, read_time: Optional[float] = None) -> Optional[Barcode]: + logger.info("Scanning barcode (read_time=%s).", read_time) + return Barcode( + data=self.barcode, symbology="Code 128 (Subset B and C)", position_on_resource="front" + ) diff --git a/pylabrobot/capabilities/bulk_dispensers/__init__.py b/pylabrobot/capabilities/bulk_dispensers/__init__.py new file mode 100644 index 00000000000..945e0c098ab --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/__init__.py @@ -0,0 +1,2 @@ +from .peristaltic import PeristalticDispensing8, PeristalticDispensingBackend8 +from .syringe import SyringeDispensing8, SyringeDispensingBackend8 diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py new file mode 100644 index 00000000000..ac428e2b4e2 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py @@ -0,0 +1,3 @@ +from .backend8 import PeristalticDispensingBackend8 +from .chatterbox8 import PeristalticDispensingChatterboxBackend8 +from .peristaltic8 import PeristalticDispensing8 diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/backend8.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/backend8.py new file mode 100644 index 00000000000..6320f6b3400 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/backend8.py @@ -0,0 +1,58 @@ +from abc import ABCMeta, abstractmethod +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Plate + + +class PeristalticDispensingBackend8(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for peristaltic pump dispensing devices.""" + + @abstractmethod + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the peristaltic pump. + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime peristaltic fluid lines. + + Args: + plate: Target plate. + volume: Prime volume in uL (mutually exclusive with duration). + duration: Prime duration in seconds (mutually exclusive with volume). + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Purge peristaltic fluid lines. + + Args: + plate: Target plate. + volume: Purge volume in uL (mutually exclusive with duration). + duration: Purge duration in seconds (mutually exclusive with volume). + backend_params: Backend-specific parameters. + """ diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox8.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox8.py new file mode 100644 index 00000000000..8559bebc486 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox8.py @@ -0,0 +1,49 @@ +import logging +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Plate + +from .backend8 import PeristalticDispensingBackend8 + +logger = logging.getLogger(__name__) + + +class PeristalticDispensingChatterboxBackend8(PeristalticDispensingBackend8): + """Chatterbox backend for device-free testing.""" + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Dispensing volumes %s to plate '%s'.", volumes, plate.name) + + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info( + "Priming peristaltic lines for plate '%s' (volume=%s, duration=%s).", + plate.name, + volume, + duration, + ) + + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info( + "Purging peristaltic lines for plate '%s' (volume=%s, duration=%s).", + plate.name, + volume, + duration, + ) diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic8.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic8.py new file mode 100644 index 00000000000..a08107fca3b --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic8.py @@ -0,0 +1,73 @@ +from typing import Dict, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import Plate + +from .backend8 import PeristalticDispensingBackend8 + + +class PeristalticDispensing8(Capability): + """Peristaltic dispensing capability. + + See :doc:`/user_guide/capabilities/dispensing/peristaltic` for a walkthrough. + """ + + NUM_COLUMNS = 12 + + def __init__(self, backend: PeristalticDispensingBackend8): + super().__init__(backend=backend) + self.backend: PeristalticDispensingBackend8 = backend + self._plate: Optional[Plate] = None + + @property + def plate(self) -> Plate: + if self._plate is None: + raise RuntimeError("No plate assigned to this capability.") + return self._plate + + @plate.setter + def plate(self, value: Optional[Plate]): + if value is not None and self._plate is not None: + raise RuntimeError(f"A plate is already assigned ({self._plate.name}). Unassign it first.") + self._plate = value + + @need_capability_ready + async def dispense( + self, + volumes: Union[float, Dict[int, float]], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the peristaltic pump. + + Args: + volumes: Volume in uL for all columns (float), or a mapping of 1-indexed + column number to volume in uL (dict). + backend_params: Backend-specific parameters. + """ + if isinstance(volumes, (int, float)): + volumes = {c: float(volumes) for c in range(1, self.NUM_COLUMNS + 1)} + await self.backend.dispense(plate=self.plate, volumes=volumes, backend_params=backend_params) + + @need_capability_ready + async def prime( + self, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime peristaltic fluid lines.""" + await self.backend.prime( + plate=self.plate, volume=volume, duration=duration, backend_params=backend_params + ) + + @need_capability_ready + async def purge( + self, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Purge peristaltic fluid lines.""" + await self.backend.purge( + plate=self.plate, volume=volume, duration=duration, backend_params=backend_params + ) diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py b/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py new file mode 100644 index 00000000000..bb37e0ca2cc --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py @@ -0,0 +1,3 @@ +from .backend8 import SyringeDispensingBackend8 +from .chatterbox8 import SyringeDispensingChatterboxBackend8 +from .syringe8 import SyringeDispensing8 diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/backend8.py b/pylabrobot/capabilities/bulk_dispensers/syringe/backend8.py new file mode 100644 index 00000000000..cd5348f01c7 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/backend8.py @@ -0,0 +1,40 @@ +from abc import ABCMeta, abstractmethod +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Plate + + +class SyringeDispensingBackend8(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for syringe pump dispensing devices.""" + + @abstractmethod + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the syringe pump. + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + Example: {1: 100, 2: 100, 3: 200, 7: 50} + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime the syringe pump system. + + Args: + plate: Target plate. + volume: Prime volume in uL. + backend_params: Backend-specific parameters. + """ diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox8.py b/pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox8.py new file mode 100644 index 00000000000..f013d2786f6 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox8.py @@ -0,0 +1,29 @@ +import logging +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Plate + +from .backend8 import SyringeDispensingBackend8 + +logger = logging.getLogger(__name__) + + +class SyringeDispensingChatterboxBackend8(SyringeDispensingBackend8): + """Chatterbox backend for device-free testing.""" + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Dispensing volumes %s to plate '%s'.", volumes, plate.name) + + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Priming syringe pump for plate '%s' (volume=%s).", plate.name, volume) diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/syringe8.py b/pylabrobot/capabilities/bulk_dispensers/syringe/syringe8.py new file mode 100644 index 00000000000..189259e77d0 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/syringe8.py @@ -0,0 +1,58 @@ +from typing import Dict, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import Plate + +from .backend8 import SyringeDispensingBackend8 + + +class SyringeDispensing8(Capability): + """Syringe dispensing capability. + + See :doc:`/user_guide/capabilities/dispensing/syringe` for a walkthrough. + """ + + NUM_COLUMNS = 12 + + def __init__(self, backend: SyringeDispensingBackend8): + super().__init__(backend=backend) + self.backend: SyringeDispensingBackend8 = backend + self._plate: Optional[Plate] = None + + @property + def plate(self) -> Plate: + if self._plate is None: + raise RuntimeError("No plate assigned to this capability.") + return self._plate + + @plate.setter + def plate(self, value: Optional[Plate]): + if value is not None and self._plate is not None: + raise RuntimeError(f"A plate is already assigned ({self._plate.name}). Unassign it first.") + self._plate = value + + @need_capability_ready + async def dispense( + self, + volumes: Union[float, Dict[int, float]], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the syringe pump. + + Args: + volumes: Volume in uL for all columns (float), or a mapping of 1-indexed + column number to volume in uL (dict). + backend_params: Backend-specific parameters. + """ + if isinstance(volumes, (int, float)): + volumes = {c: float(volumes) for c in range(1, self.NUM_COLUMNS + 1)} + await self.backend.dispense(plate=self.plate, volumes=volumes, backend_params=backend_params) + + @need_capability_ready + async def prime( + self, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime the syringe pump system.""" + await self.backend.prime(plate=self.plate, volume=volume, backend_params=backend_params) diff --git a/pylabrobot/capabilities/capability.py b/pylabrobot/capabilities/capability.py new file mode 100644 index 00000000000..12aec1f77fd --- /dev/null +++ b/pylabrobot/capabilities/capability.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import functools +import sys +from abc import ABC +from typing import Any, Awaitable, Callable, Optional, TypeVar + +from pylabrobot.serializer import SerializableMixin + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec +else: + from typing import ParamSpec + +_P = ParamSpec("_P") +_R = TypeVar("_R", bound=Awaitable[Any]) + + +class CapabilityBackend(ABC): + """Base class for capability-specific backends.""" + + async def _on_setup(self, backend_params: Optional["BackendParams"] = None): + """Called when the parent capability is set up.""" + + async def _on_stop(self): + """Called when the parent capability is stopped.""" + + +def need_capability_ready(func: Callable[_P, _R]) -> Callable[_P, _R]: + """Decorator for methods that require the capability to be set up. + + Checked by verifying `self.setup_finished` is `True`. + + Raises: + RuntimeError: If the capability is not set up. + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + if not isinstance(args[0], Capability): + raise RuntimeError("The first argument must be a Capability.") + self = args[0] + + if not self.setup_finished: + raise RuntimeError("The capability has not been set up. Call setup() on the parent device.") + return await func(*args, **kwargs) + + return wrapper + + +class _BackendParamsMeta(type): + """Metaclass that makes isinstance checks survive notebook autoreload. + + After autoreload, class objects are recreated so old instances fail normal + isinstance checks. This falls back to comparing the qualified class name + and module, which stay stable across reloads. + """ + + def __instancecheck__(cls, instance): + if super().__instancecheck__(instance): + return True + return ( + type(instance).__qualname__ == cls.__qualname__ + and type(instance).__module__ == cls.__module__ + ) + + +class BackendParams(SerializableMixin, metaclass=_BackendParamsMeta): + """Base class for backend-specific parameter dataclasses.""" + + +class Capability(ABC): + """Base class for device capabilities. + + Capabilities are owned by a Device and share its driver. They are not Resources + and do not appear in the resource tree. The parent Device is responsible for calling + `_on_setup()` and `_on_stop()` during its own setup/stop lifecycle. + """ + + def __init__(self, backend: CapabilityBackend): + self.backend = backend + self._setup_finished = False + + @property + def setup_finished(self) -> bool: + return self._setup_finished + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + """Called by the parent Device after driver.setup() completes.""" + await self.backend._on_setup(backend_params=backend_params) + self._setup_finished = True + + async def _on_stop(self): + """Called by the parent Device before driver.stop().""" + await self.backend._on_stop() + self._setup_finished = False diff --git a/pylabrobot/capabilities/centrifuging/__init__.py b/pylabrobot/capabilities/centrifuging/__init__.py new file mode 100644 index 00000000000..8dd360fd3eb --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/__init__.py @@ -0,0 +1,9 @@ +from .backend import CentrifugeBackend +from .centrifuging import Centrifuge +from .errors import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) diff --git a/pylabrobot/capabilities/centrifuging/backend.py b/pylabrobot/capabilities/centrifuging/backend.py new file mode 100644 index 00000000000..335d8d95060 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/backend.py @@ -0,0 +1,56 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.serializer import SerializableMixin + + +class CentrifugeBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for centrifuge devices.""" + + @abstractmethod + async def open_door(self) -> None: + """Open the centrifuge door.""" + + @abstractmethod + async def close_door(self) -> None: + """Close the centrifuge door.""" + + @abstractmethod + async def lock_door(self) -> None: + """Lock the centrifuge door.""" + + @abstractmethod + async def unlock_door(self) -> None: + """Unlock the centrifuge door.""" + + @abstractmethod + async def go_to_bucket1(self) -> None: + """Rotate to bucket 1 position.""" + + @abstractmethod + async def go_to_bucket2(self) -> None: + """Rotate to bucket 2 position.""" + + @abstractmethod + async def lock_bucket(self) -> None: + """Lock the bucket.""" + + @abstractmethod + async def unlock_bucket(self) -> None: + """Unlock the bucket.""" + + @abstractmethod + async def spin( + self, + g: float, + duration: float, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + """Start a spin cycle. + + Args: + g: The g-force to spin at. + duration: The duration of the spin in seconds (time at speed). + backend_params: Vendor-specific parameters. + """ diff --git a/pylabrobot/capabilities/centrifuging/centrifuging.py b/pylabrobot/capabilities/centrifuging/centrifuging.py new file mode 100644 index 00000000000..00d5239dd57 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/centrifuging.py @@ -0,0 +1,86 @@ +from typing import Optional, Tuple + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.resources import ResourceHolder +from pylabrobot.serializer import SerializableMixin + +from .backend import CentrifugeBackend + + +class Centrifuge(Capability): + """Centrifuging capability. + + See :doc:`/user_guide/capabilities/centrifuging` for a walkthrough. + """ + + def __init__( + self, + backend: CentrifugeBackend, + buckets: Tuple[ResourceHolder, ResourceHolder], + ): + super().__init__(backend=backend) + self.backend: CentrifugeBackend = backend + self._door_open = False + self._at_bucket: Optional[ResourceHolder] = None + self.bucket1, self.bucket2 = buckets + + @need_capability_ready + async def open_door(self) -> None: + await self.backend.open_door() + self._door_open = True + + @need_capability_ready + async def close_door(self) -> None: + await self.backend.close_door() + self._door_open = False + + @property + def door_open(self) -> bool: + return self._door_open + + @need_capability_ready + async def lock_door(self) -> None: + await self.backend.lock_door() + + @need_capability_ready + async def unlock_door(self) -> None: + await self.backend.unlock_door() + + @need_capability_ready + async def lock_bucket(self) -> None: + await self.backend.lock_bucket() + + @need_capability_ready + async def unlock_bucket(self) -> None: + await self.backend.unlock_bucket() + + @need_capability_ready + async def go_to_bucket1(self) -> None: + await self.backend.go_to_bucket1() + self._at_bucket = self.bucket1 + + @need_capability_ready + async def go_to_bucket2(self) -> None: + await self.backend.go_to_bucket2() + self._at_bucket = self.bucket2 + + @need_capability_ready + async def spin( + self, + g: float, + duration: float, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + """Start a spin cycle. + + Args: + g: The g-force to spin at. + duration: The duration of the spin in seconds (time at speed). + backend_params: Vendor-specific parameters. + """ + await self.backend.spin(g=g, duration=duration, backend_params=backend_params) + self._at_bucket = None + + @property + def at_bucket(self) -> Optional[ResourceHolder]: + return self._at_bucket diff --git a/pylabrobot/capabilities/centrifuging/chatterbox.py b/pylabrobot/capabilities/centrifuging/chatterbox.py new file mode 100644 index 00000000000..a710a49e461 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/chatterbox.py @@ -0,0 +1,44 @@ +import logging +from typing import Optional + +from pylabrobot.serializer import SerializableMixin + +from .backend import CentrifugeBackend + +logger = logging.getLogger(__name__) + + +class CentrifugeChatterboxBackend(CentrifugeBackend): + """Chatterbox backend for device-free testing.""" + + async def open_door(self) -> None: + logger.info("Opening centrifuge door.") + + async def close_door(self) -> None: + logger.info("Closing centrifuge door.") + + async def lock_door(self) -> None: + logger.info("Locking centrifuge door.") + + async def unlock_door(self) -> None: + logger.info("Unlocking centrifuge door.") + + async def go_to_bucket1(self) -> None: + logger.info("Rotating to bucket 1.") + + async def go_to_bucket2(self) -> None: + logger.info("Rotating to bucket 2.") + + async def lock_bucket(self) -> None: + logger.info("Locking bucket.") + + async def unlock_bucket(self) -> None: + logger.info("Unlocking bucket.") + + async def spin( + self, + g: float, + duration: float, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + logger.info("Spinning at %s g for %s seconds.", g, duration) diff --git a/pylabrobot/centrifuge/standard.py b/pylabrobot/capabilities/centrifuging/errors.py similarity index 100% rename from pylabrobot/centrifuge/standard.py rename to pylabrobot/capabilities/centrifuging/errors.py index 164730bb848..b1c28ea942d 100644 --- a/pylabrobot/centrifuge/standard.py +++ b/pylabrobot/capabilities/centrifuging/errors.py @@ -1,7 +1,3 @@ -class LoaderNoPlateError(Exception): - pass - - class CentrifugeDoorError(Exception): pass @@ -16,3 +12,7 @@ class BucketNoPlateError(Exception): class BucketHasPlateError(Exception): pass + + +class LoaderNoPlateError(Exception): + pass diff --git a/pylabrobot/capabilities/fan_control/__init__.py b/pylabrobot/capabilities/fan_control/__init__.py new file mode 100644 index 00000000000..d4360454b29 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/__init__.py @@ -0,0 +1,2 @@ +from .backend import FanBackend +from .fan_control import Fan diff --git a/pylabrobot/capabilities/fan_control/backend.py b/pylabrobot/capabilities/fan_control/backend.py new file mode 100644 index 00000000000..e8d4b9945e1 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/backend.py @@ -0,0 +1,15 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class FanBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for fan devices.""" + + @abstractmethod + async def turn_on(self, intensity: int) -> None: + """Run the fan at the given intensity (0-100).""" + + @abstractmethod + async def turn_off(self) -> None: + """Stop the fan.""" diff --git a/pylabrobot/capabilities/fan_control/chatterbox.py b/pylabrobot/capabilities/fan_control/chatterbox.py new file mode 100644 index 00000000000..fec6d8e5277 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/chatterbox.py @@ -0,0 +1,15 @@ +import logging + +from .backend import FanBackend + +logger = logging.getLogger(__name__) + + +class FanChatterboxBackend(FanBackend): + """Chatterbox backend for device-free testing.""" + + async def turn_on(self, intensity: int) -> None: + logger.info("Turning fan on at %s%%.", intensity) + + async def turn_off(self) -> None: + logger.info("Turning fan off.") diff --git a/pylabrobot/capabilities/fan_control/fan_control.py b/pylabrobot/capabilities/fan_control/fan_control.py new file mode 100644 index 00000000000..cbc83209a24 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/fan_control.py @@ -0,0 +1,38 @@ +import asyncio + +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import FanBackend + + +class Fan(Capability): + """Fan control capability. + + See :doc:`/user_guide/capabilities/fan-control` for a walkthrough. + """ + + def __init__(self, backend: FanBackend): + super().__init__(backend=backend) + self.backend: FanBackend = backend + + @need_capability_ready + async def turn_on(self, intensity: int, duration=None): + """Run the fan. + + Args: + intensity: integer percent between 0 and 100. + duration: time to run the fan for in seconds. If None, run until turn_off is called. + """ + await self.backend.turn_on(intensity=intensity) + if duration is not None: + await asyncio.sleep(duration) + await self.backend.turn_off() + + @need_capability_ready + async def turn_off(self): + """Turn the fan off.""" + await self.backend.turn_off() + + async def _on_stop(self): + await self.backend.turn_off() + await super()._on_stop() diff --git a/pylabrobot/capabilities/humidity_controlling/__init__.py b/pylabrobot/capabilities/humidity_controlling/__init__.py new file mode 100644 index 00000000000..ece6c893114 --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/__init__.py @@ -0,0 +1,2 @@ +from .backend import HumidityControllerBackend +from .humidity_controller import HumidityController diff --git a/pylabrobot/capabilities/humidity_controlling/backend.py b/pylabrobot/capabilities/humidity_controlling/backend.py new file mode 100644 index 00000000000..e33f18b54ea --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/backend.py @@ -0,0 +1,20 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class HumidityControllerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for humidity controllers.""" + + @property + @abstractmethod + def supports_humidity_control(self) -> bool: + """Whether this backend can set humidity (vs read-only monitoring).""" + + @abstractmethod + async def set_humidity(self, humidity: float): + """Set the target humidity as a fraction 0.0-1.0.""" + + @abstractmethod + async def request_current_humidity(self) -> float: + """Get the current humidity as a fraction 0.0-1.0.""" diff --git a/pylabrobot/capabilities/humidity_controlling/chatterbox.py b/pylabrobot/capabilities/humidity_controlling/chatterbox.py new file mode 100644 index 00000000000..d484b0675fe --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/chatterbox.py @@ -0,0 +1,23 @@ +import logging + +from .backend import HumidityControllerBackend + +logger = logging.getLogger(__name__) + + +class HumidityControllerChatterboxBackend(HumidityControllerBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._humidity = 0.5 + + @property + def supports_humidity_control(self) -> bool: + return True + + async def set_humidity(self, humidity: float): + logger.info("Setting humidity to %s.", humidity) + self._humidity = humidity + + async def request_current_humidity(self) -> float: + return self._humidity diff --git a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py new file mode 100644 index 00000000000..9b22e92b07a --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py @@ -0,0 +1,33 @@ +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import HumidityControllerBackend + + +class HumidityController(Capability): + """Humidity control capability. + + See :doc:`/user_guide/capabilities/humidity-control` for a walkthrough. + """ + + def __init__(self, backend: HumidityControllerBackend): + super().__init__(backend=backend) + self.backend: HumidityControllerBackend = backend + + @need_capability_ready + async def set_humidity(self, humidity: float): + """Set the target humidity as a fraction 0.0-1.0. + + Raises: + ValueError: If the backend does not support humidity control. + """ + if not self.backend.supports_humidity_control: + raise ValueError("Backend does not support humidity control (read-only).") + await self.backend.set_humidity(humidity) + + @need_capability_ready + async def request_humidity(self) -> float: + """Get the current humidity as a fraction 0.0-1.0.""" + return await self.backend.request_current_humidity() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/liquid_handling/__init__.py b/pylabrobot/capabilities/liquid_handling/__init__.py new file mode 100644 index 00000000000..f193d242e57 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/__init__.py @@ -0,0 +1,18 @@ +from .errors import ChannelizedError, NoChannelError +from .head96 import Head96 +from .head96_backend import Head96Backend +from .pip import PIP +from .pip_backend import PIPBackend +from .standard import ( + Aspiration, + Dispense, + DropTipRack, + Mix, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + TipDrop, +) diff --git a/pylabrobot/capabilities/liquid_handling/errors.py b/pylabrobot/capabilities/liquid_handling/errors.py new file mode 100644 index 00000000000..4a8283fd0d6 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/errors.py @@ -0,0 +1,26 @@ +"""Errors for liquid handling operations.""" + +from typing import Dict + + +class NoChannelError(Exception): + """Raised when no channel is available.""" + + +class BlowOutVolumeError(Exception): + """Raised when blow-out air volume is invalid.""" + + +class ChannelizedError(Exception): + """Raised by multi-channel operations. Contains per-channel errors.""" + + def __init__(self, errors: Dict[int, Exception], **kwargs): + self.errors = errors + self.kwargs = kwargs + + def __str__(self) -> str: + kwarg_string = ", ".join([f"{k}={v}" for k, v in self.kwargs.items()]) + return f"ChannelizedError(errors={self.errors}, {kwarg_string})" + + def __len__(self) -> int: + return len(self.errors) diff --git a/pylabrobot/capabilities/liquid_handling/head96.py b/pylabrobot/capabilities/liquid_handling/head96.py new file mode 100644 index 00000000000..16acc005486 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/head96.py @@ -0,0 +1,581 @@ +"""Capability for 96-head liquid handling.""" + +import logging +from typing import Dict, List, Optional, Sequence, Union, cast + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import ( + Container, + Coordinate, + Deck, + Plate, + Tip, + TipRack, + TipTracker, + Trash, + Well, + does_tip_tracking, + does_volume_tracking, +) + +from .head96_backend import Head96Backend +from .standard import ( + DropTipRack, + Mix, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) + +logger = logging.getLogger(__name__) + + +class Head96(Capability): + """96-head liquid handling: pick up tips, aspirate, dispense, drop tips. + + Faithfully ports the 96-head logic from the legacy LiquidHandler, including + tip tracking with commit/rollback, volume tracking, partial tip pickup, + single-container (trough) support, and convenience methods. + + See :doc:`/user_guide/capabilities/head96` for a walkthrough. + """ + + def __init__( + self, + backend: Head96Backend, + deck: Deck, + default_offset: Coordinate = Coordinate.zero(), + ): + super().__init__(backend=backend) + self.backend: Head96Backend = backend + self.head: Dict[int, TipTracker] = {} + self.default_offset: Coordinate = default_offset + self.deck = deck + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + await super()._on_setup(backend_params=backend_params) + self.head = {c: TipTracker(thing=f"96Head Channel {c}") for c in range(96)} + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Get the tips currently mounted on the 96-head. + + Returns: + A list of 96 tips, or None for channels without a tip. + """ + return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] + + def update_head_state(self, state: Dict[int, Optional[Tip]]): + """Update the state of the 96-head. + + All keys must be valid channels (0-95). Channels not in `state` keep their current state. + """ + if not set(state.keys()).issubset(set(self.head.keys())): + raise ValueError("Invalid channel.") + for channel, tip in state.items(): + if tip is None: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + else: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + self.head[channel].add_tip(tip) + + def clear_head_state(self): + """Clear all tips from the 96-head.""" + self.update_head_state({c: None for c in self.head.keys()}) + + def serialize_state(self) -> Dict: + """Serialize the 96-head state for saving/restoring.""" + return {channel: tracker.serialize() for channel, tracker in self.head.items()} + + def load_state(self, state: Dict): + """Load 96-head state from a serialized dict.""" + for channel, tracker_state in state.items(): + self.head[channel].load_state(tracker_state) + + def _get_origin_tip_rack(self) -> Optional[TipRack]: + """Get the tip rack where the 96-head tips were picked up from. + + Returns None if no tips are mounted. Raises if tips are from different racks. + """ + tip_spot = self.head[0].get_tip_origin() + if tip_spot is None: + return None + tip_rack = tip_spot.parent + if tip_rack is None: + raise RuntimeError("No tip rack found for tip") + for i in range(tip_rack.num_items): + other_tip_spot = self.head[i].get_tip_origin() + if other_tip_spot is None: + raise RuntimeError("Not all channels have a tip origin") + if other_tip_spot.parent != tip_rack: + raise RuntimeError("All tips must be from the same tip rack") + return tip_rack + + @staticmethod + def _check_96_head_fits_in_container(container: Container) -> bool: + """Check if the 96 head can fit in the given container.""" + tip_width = 2 # approximation + distance_between_tips = 9 + return ( + container.get_absolute_size_x() >= tip_width + distance_between_tips * 11 + and container.get_absolute_size_y() >= tip_width + distance_between_tips * 7 + ) + + @need_capability_ready + async def pick_up_tips( + self, + tip_rack: TipRack, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from a 96-tip rack. + + Not all tip spots need to have tips — only those with tips will be picked up. + + Examples: + >>> await head96.pick_up_tips(my_tiprack) + + Args: + tip_rack: The tip rack to pick up from. Must have 96 positions. + offset: Additional offset (added to default_offset). + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not isinstance(tip_rack, TipRack): + raise TypeError(f"Resource must be a TipRack, got {tip_rack}") + if tip_rack.num_items != 96: + raise ValueError("Tip rack must have 96 tips") + + # queue operation on all tip trackers + tips: List[Optional[Tip]] = [] + for i, tip_spot in enumerate(tip_rack.get_all_items()): + if not does_tip_tracking() and self.head[i].has_tip: + self.head[i].remove_tip() + # only add tips where one is present + if tip_spot.has_tip(): + self.head[i].add_tip(tip_spot.get_tip(), origin=tip_spot, commit=False) + tips.append(tip_spot.get_tip()) + else: + tips.append(None) + if does_tip_tracking() and not tip_spot.tracker.is_disabled and tip_spot.has_tip(): + tip_spot.tracker.remove_tip() + + pickup_operation = PickupTipRack(resource=tip_rack, offset=offset, tips=tips) + try: + await self.backend.pick_up_tips96(pickup=pickup_operation, backend_params=backend_params) + except Exception as error: + for i, tip_spot in enumerate(tip_rack.get_all_items()): + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.rollback() + self.head[i].rollback() + raise error + else: + for i, tip_spot in enumerate(tip_rack.get_all_items()): + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.commit() + self.head[i].commit() + + @need_capability_ready + async def drop_tips( + self, + resource: Union[TipRack, Trash], + offset: Coordinate = Coordinate.zero(), + allow_nonzero_volume: bool = False, + backend_params: Optional[BackendParams] = None, + ): + """Drop tips using the 96-head. + + Examples: + >>> await head96.drop_tips(my_tiprack) + >>> await head96.drop_tips(trash) + + Args: + resource: The tip rack or trash to drop tips to. + offset: Additional offset (added to default_offset). + allow_nonzero_volume: If True, drop even if tips have liquid. + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not isinstance(resource, (TipRack, Trash)): + raise TypeError(f"Resource must be a TipRack or Trash, got {resource}") + if isinstance(resource, TipRack) and resource.num_items != 96: + raise ValueError("Tip rack must have 96 tips") + + # queue operation on all tip trackers + for i in range(96): + if not self.head[i].has_tip: + continue + tip = self.head[i].get_tip() + if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume and does_volume_tracking(): + raise RuntimeError( + f"Cannot drop tip with volume {tip.tracker.get_used_volume()} on channel {i}" + ) + if isinstance(resource, TipRack): + tip_spot = resource.get_item(i) + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.add_tip(tip, commit=False) + self.head[i].remove_tip() + + drop_operation = DropTipRack(resource=resource, offset=offset) + try: + await self.backend.drop_tips96(drop=drop_operation, backend_params=backend_params) + except Exception as e: + for i in range(96): + if isinstance(resource, TipRack): + tip_spot = resource.get_item(i) + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.rollback() + self.head[i].rollback() + raise e + else: + for i in range(96): + if isinstance(resource, TipRack): + tip_spot = resource.get_item(i) + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.commit() + self.head[i].commit() + + @need_capability_ready + async def return_tips( + self, + allow_nonzero_volume: bool = False, + offset: Coordinate = Coordinate.zero(), + drop_backend_params: Optional[BackendParams] = None, + ): + """Return the tips on the 96-head to the tip rack they were picked up from. + + Args: + allow_nonzero_volume: If True, return even if tips have liquid. + offset: Additional offset. + drop_backend_params: Vendor-specific parameters for the drop. + + Raises: + RuntimeError: If no tips have been picked up. + """ + tip_rack = self._get_origin_tip_rack() + if tip_rack is None: + raise RuntimeError("No tips have been picked up with the 96 head") + await self.drop_tips( + tip_rack, + allow_nonzero_volume=allow_nonzero_volume, + offset=offset, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def discard_tips( + self, + trash: Optional[Trash] = None, + allow_nonzero_volume: bool = True, + drop_backend_params: Optional[BackendParams] = None, + ): + """Permanently discard tips from the 96-head into the trash. + + Args: + trash: The trash resource. If None, automatically finds the 96-head trash on the deck. + allow_nonzero_volume: If True, discard even if tips have liquid. + drop_backend_params: Vendor-specific parameters for the drop. + """ + if trash is None: + if self.deck is None: + raise ValueError("No trash provided and no deck set on Head96. Pass trash explicitly.") + trash = self.deck.get_trash_area96() + await self.drop_tips( + trash, allow_nonzero_volume=allow_nonzero_volume, backend_params=drop_backend_params + ) + + @need_capability_ready + async def aspirate( + self, + resource: Union[Plate, Container, List[Well]], + volume: float, + offset: Coordinate = Coordinate.zero(), + flow_rate: Optional[float] = None, + liquid_height: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, + backend_params: Optional[BackendParams] = None, + ): + """Aspirate from all wells in a plate or from a container. + + Examples: + >>> await head96.aspirate(plate, volume=50) + >>> await head96.aspirate(trough, volume=50) + + Args: + resource: A Plate, Container, or list of 96 Wells. + volume: Volume to aspirate per channel. + offset: Additional offset (added to default_offset). + flow_rate: Flow rate in ul/s. None = machine default. + liquid_height: Liquid height in mm from bottom. None = machine default. + blow_out_air_volume: Air volume to aspirate after liquid (ul). + mix: Mix parameters. + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not ( + isinstance(resource, (Plate, Container)) + or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) + ): + raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") + + tips = [ch.get_tip() if ch.has_tip else None for ch in self.head.values()] + + volume = float(volume) + flow_rate = float(flow_rate) if flow_rate is not None else None + blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None + + # resolve resource to containers + containers: Sequence[Container] + if isinstance(resource, Plate): + if resource.has_lid(): + raise ValueError("Aspirating from plate with lid") + containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] + elif isinstance(resource, Container): + containers = [resource] + elif isinstance(resource, list): + containers = resource + else: + raise TypeError(f"Unexpected resource type: {type(resource)}") + + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + + if len(containers) == 1: # single container (trough) + container = containers[0] + if not self._check_96_head_fits_in_container(container): + raise ValueError("Container too small to accommodate 96 head") + + for tip in tips: + if tip is None: + continue + if not container.tracker.is_disabled and does_volume_tracking(): + container.tracker.remove_liquid(volume=volume) + tip.tracker.add_liquid(volume=volume) + + aspiration = MultiHeadAspirationContainer( + container=container, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + else: # plate / list of wells + plate = containers[0].parent + for well in containers: + if well.parent != plate: + raise ValueError("All wells must be in the same plate") + if len(containers) != 96: + raise ValueError(f"aspirate96 expects 96 containers when a list, got {len(containers)}") + + for well, tip in zip(containers, tips): + if tip is None: + continue + if not well.tracker.is_disabled and does_volume_tracking(): + well.tracker.remove_liquid(volume=volume) + tip.tracker.add_liquid(volume=volume) + + aspiration = MultiHeadAspirationPlate( + wells=cast(List[Well], containers), + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + + try: + await self.backend.aspirate96(aspiration=aspiration, backend_params=backend_params) + except Exception: + for tip in tips: + if tip is not None: + tip.tracker.rollback() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.rollback() + raise + else: + for tip in tips: + if tip is not None: + tip.tracker.commit() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.commit() + + @need_capability_ready + async def dispense( + self, + resource: Union[Plate, Container, List[Well]], + volume: float, + offset: Coordinate = Coordinate.zero(), + flow_rate: Optional[float] = None, + liquid_height: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, + backend_params: Optional[BackendParams] = None, + ): + """Dispense to all wells in a plate or to a container. + + Examples: + >>> await head96.dispense(plate, volume=50) + + Args: + resource: A Plate, Container, or list of 96 Wells. + volume: Volume to dispense per channel. + offset: Additional offset (added to default_offset). + flow_rate: Flow rate in ul/s. None = machine default. + liquid_height: Liquid height in mm from bottom. None = machine default. + blow_out_air_volume: Air volume to dispense after liquid (ul). + mix: Mix parameters. + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not ( + isinstance(resource, (Plate, Container)) + or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) + ): + raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") + + tips = [ch.get_tip() if ch.has_tip else None for ch in self.head.values()] + + volume = float(volume) + flow_rate = float(flow_rate) if flow_rate is not None else None + blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None + + # resolve resource to containers + containers: Sequence[Container] + if isinstance(resource, Plate): + if resource.has_lid(): + raise ValueError("Dispensing to plate with lid is not possible. Remove the lid first.") + containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] + elif isinstance(resource, Container): + containers = [resource] + elif isinstance(resource, list): + containers = resource + else: + raise TypeError(f"Unexpected resource type: {type(resource)}") + + # remove liquid from tips + for tip in tips: + if tip is None: + continue + if does_volume_tracking(): + tip.tracker.remove_liquid(volume=volume) + elif tip.tracker.get_used_volume() <= volume: + tip.tracker.remove_liquid(volume=min(tip.tracker.get_used_volume(), volume)) + + dispense_op: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] + + if len(containers) == 1: # single container (trough) + container = containers[0] + if not self._check_96_head_fits_in_container(container): + raise ValueError("Container too small to accommodate 96 head") + + if not container.tracker.is_disabled and does_volume_tracking(): + container.tracker.add_liquid(volume=len([t for t in tips if t is not None]) * volume) + + dispense_op = MultiHeadDispenseContainer( + container=container, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + else: # plate / list of wells + plate = containers[0].parent + for well in containers: + if well.parent != plate: + raise ValueError("All wells must be in the same plate") + if len(containers) != 96: + raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") + + for well, tip in zip(containers, tips): + if tip is None: + continue + if not well.tracker.is_disabled and does_volume_tracking(): + well.tracker.add_liquid(volume=volume) + + dispense_op = MultiHeadDispensePlate( + wells=cast(List[Well], containers), + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + + try: + await self.backend.dispense96(dispense=dispense_op, backend_params=backend_params) + except Exception: + for tip in tips: + if tip is not None: + tip.tracker.rollback() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.rollback() + raise + else: + for tip in tips: + if tip is not None: + tip.tracker.commit() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.commit() + + @need_capability_ready + async def stamp( + self, + source: Plate, + target: Plate, + volume: float, + aspiration_flow_rate: Optional[float] = None, + dispense_flow_rate: Optional[float] = None, + aspirate_backend_params: Optional[BackendParams] = None, + dispense_backend_params: Optional[BackendParams] = None, + ): + """Stamp (aspirate and dispense) one plate onto another. + + Args: + source: The source plate. + target: The target plate. + volume: The volume to transfer. + aspiration_flow_rate: Flow rate for aspiration (ul/s). + dispense_flow_rate: Flow rate for dispense (ul/s). + aspirate_backend_params: Vendor-specific parameters for aspiration. + dispense_backend_params: Vendor-specific parameters for dispense. + """ + if (source.num_items_x, source.num_items_y) != (target.num_items_x, target.num_items_y): + raise ValueError("Source and target plates must be the same shape") + + await self.aspirate( + resource=source, + volume=volume, + flow_rate=aspiration_flow_rate, + backend_params=aspirate_backend_params, + ) + await self.dispense( + resource=target, + volume=volume, + flow_rate=dispense_flow_rate, + backend_params=dispense_backend_params, + ) diff --git a/pylabrobot/capabilities/liquid_handling/head96_backend.py b/pylabrobot/capabilities/liquid_handling/head96_backend.py new file mode 100644 index 00000000000..19a6d3299b0 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/head96_backend.py @@ -0,0 +1,45 @@ +"""Abstract backend for 96-head liquid handling.""" + +from abc import ABCMeta, abstractmethod +from typing import Optional, Union + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend + +from .standard import ( + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) + + +class Head96Backend(CapabilityBackend, metaclass=ABCMeta): + """Backend for 96-head liquid handling operations.""" + + @abstractmethod + async def pick_up_tips96( + self, pickup: PickupTipRack, backend_params: Optional[BackendParams] = None + ): + """Pick up tips from a tip rack using the 96-head.""" + + @abstractmethod + async def drop_tips96(self, drop: DropTipRack, backend_params: Optional[BackendParams] = None): + """Drop tips using the 96-head.""" + + @abstractmethod + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate using the 96-head.""" + + @abstractmethod + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + backend_params: Optional[BackendParams] = None, + ): + """Dispense using the 96-head.""" diff --git a/pylabrobot/capabilities/liquid_handling/pip.py b/pylabrobot/capabilities/liquid_handling/pip.py new file mode 100644 index 00000000000..f9a1d833c67 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/pip.py @@ -0,0 +1,806 @@ +"""Capability for independent-channel liquid handling.""" + +import contextlib +import logging +from typing import Dict, Generator, List, Literal, Optional, Sequence, Union + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import ( + Container, + Coordinate, + Deck, + Plate, + Tip, + TipSpot, + TipTracker, + Trash, + Well, + does_tip_tracking, + does_volume_tracking, +) +from pylabrobot.resources.errors import HasTipError + +from .errors import BlowOutVolumeError, ChannelizedError +from .pip_backend import PIPBackend +from .standard import Aspiration, Dispense, Mix, Pickup, TipDrop +from .utils import get_tight_single_resource_liquid_op_offsets + +logger = logging.getLogger(__name__) + + +class PIP(Capability): + """Independent-channel liquid handling: pick up tips, aspirate, dispense, drop tips. + + Faithfully ports the tip tracking, volume tracking, validation, spread modes, and + error handling from the legacy LiquidHandler frontend. + + See :doc:`/user_guide/capabilities/pip` for a walkthrough. + """ + + def __init__(self, backend: PIPBackend, deck: Deck): + super().__init__(backend=backend) + self.backend: PIPBackend = backend + self.deck = deck + self.head: Dict[int, TipTracker] = {} + self._default_use_channels: Optional[List[int]] = None + self._blow_out_air_volume: Optional[List[Optional[float]]] = None + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + await super()._on_setup(backend_params=backend_params) + self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} + + @property + def num_channels(self) -> int: + return self.backend.num_channels + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Get the tips currently mounted on the head. + + Returns: + A list of tips, or None for channels without a tip. + """ + return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] + + def update_head_state(self, state: Dict[int, Optional[Tip]]): + """Update the state of the head. + + All keys in `state` must be valid channels. Channels not in `state` keep their current state. + + Args: + state: A dictionary mapping channels to tips. None means no tip. + """ + if not set(state.keys()).issubset(set(self.head.keys())): + raise ValueError("Invalid channel.") + for channel, tip in state.items(): + if tip is None: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + else: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + self.head[channel].add_tip(tip) + + def clear_head_state(self): + """Clear all tips from the head.""" + self.update_head_state({c: None for c in self.head.keys()}) + + def serialize_state(self) -> Dict: + """Serialize the head state for saving/restoring.""" + return {channel: tracker.serialize() for channel, tracker in self.head.items()} + + def load_state(self, state: Dict): + """Load head state from a serialized dict.""" + for channel, tracker_state in state.items(): + self.head[channel].load_state(tracker_state) + + def _make_sure_channels_exist(self, channels: List[int]): + invalid = [c for c in channels if c not in self.head] + if invalid: + raise ValueError(f"Invalid channels: {invalid}") + + @need_capability_ready + async def pick_up_tips( + self, + tip_spots: List[TipSpot], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from tip spots. + + Examples: + Pick up all tips in the first column: + + >>> await lh.pick_up_tips(tips_resource["A1":"H1"]) + + Pick up tips on odd rows, skipping the other channels: + + >>> await lh.pick_up_tips(tips_resource["A1", "C1", "E1", "G1"], use_channels=[0, 2, 4, 6]) + + Args: + tip_spots: List of tip spots to pick up tips from. + use_channels: List of channels to use. If None, the first len(tip_spots) channels are used. + offsets: List of offsets for each pickup. Defaults to zero. + backend_params: Vendor-specific parameters. + + Raises: + HasTipError: If a channel already has a tip. + NoTipError: If a spot does not have a tip. + """ + + not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, TipSpot)] + if not_tip_spots: + raise TypeError(f"Resources must be TipSpots, got {not_tip_spots}") + + use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + tips = [tip_spot.get_tip() for tip_spot in tip_spots] + offsets = offsets or [Coordinate.zero()] * len(tip_spots) + + # check tip compatibility + if not all( + self.backend.can_pick_up_tip(channel, tip) for channel, tip in zip(use_channels, tips) + ): + cannot = [ + ch for ch, tip in zip(use_channels, tips) if not self.backend.can_pick_up_tip(ch, tip) + ] + raise RuntimeError(f"Cannot pick up tips on channels {cannot}.") + + self._make_sure_channels_exist(use_channels) + if not (len(tip_spots) == len(offsets) == len(use_channels)): + raise ValueError("Number of tips, offsets, and use_channels must be equal.") + + pickups = [Pickup(resource=ts, offset=o, tip=t) for ts, o, t in zip(tip_spots, offsets, tips)] + + # queue operations on trackers + for channel, op in zip(use_channels, pickups): + if self.head[channel].has_tip: + raise HasTipError("Channel has tip") + if does_tip_tracking() and not op.resource.tracker.is_disabled: + op.resource.tracker.remove_tip() + self.head[channel].add_tip(op.tip, origin=op.resource, commit=False) + + # execute + error: Optional[BaseException] = None + try: + await self.backend.pick_up_tips( + ops=pickups, use_channels=use_channels, backend_params=backend_params + ) + except BaseException as e: + error = e + + # determine per-channel success + successes = [error is None] * len(pickups) + if error is not None: + try: + tip_presence = await self.backend.request_tip_presence() + successes = [tip_presence[ch] is True for ch in use_channels] + except Exception as tip_presence_error: + if not isinstance(tip_presence_error, NotImplementedError): + logger.warning("Failed to query tip presence after error: %s", tip_presence_error) + if isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, pickups, successes): + if does_tip_tracking() and not op.resource.tracker.is_disabled: + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + (self.head[channel].commit if success else self.head[channel].rollback)() + + if error is not None: + raise error + + @need_capability_ready + async def drop_tips( + self, + tip_spots: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + allow_nonzero_volume: bool = False, + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to tip spots or trash. + + Args: + tip_spots: Tip spots or trash to drop to. + use_channels: List of channels to use. If None, the first len(tip_spots) channels are used. + offsets: List of offsets for each drop. Defaults to zero. + allow_nonzero_volume: If True, drop even if the tip has liquid. Otherwise raise. + backend_params: Vendor-specific parameters. + + Raises: + NoTipError: If a channel does not have a tip. + HasTipError: If a spot already has a tip. + """ + + not_valid = [ts for ts in tip_spots if not isinstance(ts, (TipSpot, Trash))] + if not_valid: + raise TypeError(f"Resources must be TipSpots or Trash, got {not_valid}") + + use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + tips = [] + for channel in use_channels: + tip = self.head[channel].get_tip() + if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume: + raise RuntimeError(f"Cannot drop tip with volume {tip.tracker.get_used_volume()}") + tips.append(tip) + + offsets = offsets or [Coordinate.zero()] * len(tip_spots) + + self._make_sure_channels_exist(use_channels) + if not (len(tip_spots) == len(offsets) == len(use_channels) == len(tips)): + raise ValueError("Number of tip_spots, offsets, use_channels, and tips must be equal.") + + drops = [TipDrop(resource=ts, offset=o, tip=t) for ts, t, o in zip(tip_spots, tips, offsets)] + + # queue operations on trackers + for channel, op in zip(use_channels, drops): + if ( + does_tip_tracking() + and isinstance(op.resource, TipSpot) + and not op.resource.tracker.is_disabled + ): + op.resource.tracker.add_tip(op.tip, commit=False) + self.head[channel].remove_tip() + + # execute + error: Optional[BaseException] = None + try: + await self.backend.drop_tips( + ops=drops, use_channels=use_channels, backend_params=backend_params + ) + except BaseException as e: + error = e + + # determine per-channel success + successes = [error is None] * len(drops) + if error is not None: + try: + tip_presence = await self.backend.request_tip_presence() + successes = [tip_presence[ch] is False for ch in use_channels] + except Exception as tip_presence_error: + if not isinstance(tip_presence_error, NotImplementedError): + logger.warning("Failed to query tip presence after error: %s", tip_presence_error) + if isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, drops, successes): + if ( + does_tip_tracking() + and isinstance(op.resource, TipSpot) + and not op.resource.tracker.is_disabled + ): + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + (self.head[channel].commit if success else self.head[channel].rollback)() + + if error is not None: + raise error + + @need_capability_ready + async def return_tips( + self, + use_channels: Optional[List[int]] = None, + allow_nonzero_volume: bool = False, + offsets: Optional[List[Coordinate]] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Return all tips currently picked up to their original place. + + Args: + use_channels: Channels to return. If None, all channels with tips are used. + allow_nonzero_volume: If True, return even if the tip has liquid. + offsets: List of offsets for each drop. + drop_backend_params: Vendor-specific parameters for the drop. + + Raises: + RuntimeError: If no tips have been picked up. + """ + + tip_spots: List[TipSpot] = [] + channels: List[int] = [] + + for channel, tracker in self.head.items(): + if use_channels is not None and channel not in use_channels: + continue + if tracker.has_tip: + origin = tracker.get_tip_origin() + if origin is None: + raise RuntimeError("No tip origin found.") + tip_spots.append(origin) + channels.append(channel) + + if len(tip_spots) == 0: + raise RuntimeError("No tips have been picked up.") + + await self.drop_tips( + tip_spots=tip_spots, + use_channels=channels, + allow_nonzero_volume=allow_nonzero_volume, + offsets=offsets, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def discard_tips( + self, + trash: Trash, + use_channels: Optional[List[int]] = None, + allow_nonzero_volume: bool = True, + offsets: Optional[List[Coordinate]] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Permanently discard tips in the trash. + + Args: + trash: The trash resource. + use_channels: Channels to discard. If None, all channels with tips are used. + allow_nonzero_volume: If True, discard even if the tip has liquid. + offsets: List of offsets for each drop. + drop_backend_params: Vendor-specific parameters for the drop. + """ + + if use_channels is None: + use_channels = [c for c, t in self.head.items() if t.has_tip] + + n = len(use_channels) + if n == 0: + raise RuntimeError("No tips have been picked up and no channels were specified.") + + trash_offsets = get_tight_single_resource_liquid_op_offsets(trash, num_channels=n) + offsets = [ + o + to if o is not None else to + for o, to in zip(offsets or [None] * n, trash_offsets) # type: ignore + ] + + await self.drop_tips( + tip_spots=[trash] * n, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def move_tips( + self, + source_tip_spots: List[TipSpot], + dest_tip_spots: List[TipSpot], + pick_up_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Move tips from one tip rack to another. + + Examples: + >>> await cap.move_tips(source_rack["A1":"A8"], dest_rack["B1":"B8"]) + """ + if len(source_tip_spots) != len(dest_tip_spots): + raise ValueError("Number of source and destination tip spots must match.") + + use_channels = list(range(len(source_tip_spots))) + await self.pick_up_tips( + tip_spots=source_tip_spots, + use_channels=use_channels, + backend_params=pick_up_backend_params, + ) + await self.drop_tips( + tip_spots=dest_tip_spots, + use_channels=use_channels, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def aspirate( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified containers. + + Examples: + Aspirate 50 uL from the first column: + + >>> await cap.aspirate(plate["A1:H1"], vols=[50]*8) + + Aspirate from a single container with multiple channels spread evenly: + + >>> await cap.aspirate([trough], vols=[50]*4, use_channels=[0,1,2,3]) + + Args: + resources: Containers to aspirate from. If a single resource is given with multiple channels, + channels are spread across it according to `spread`. + vols: Volume to aspirate per channel. + use_channels: Channels to use. Defaults to 0..len(resources)-1. + flow_rates: Flow rate per channel (ul/s). None = machine default. + offsets: Offset per channel. + liquid_height: Liquid height per channel (mm from bottom). None = machine default. + blow_out_air_volume: Air volume to aspirate after liquid (ul). None = machine default. + spread: How to space channels on a single resource: "wide", "tight", or "custom". + mix: Mix parameters per channel. + backend_params: Vendor-specific parameters. + """ + + not_containers = [r for r in resources if not isinstance(r, Container)] + if not_containers: + raise TypeError(f"Resources must be Containers, got {not_containers}") + + use_channels = use_channels or self._default_use_channels or list(range(len(resources))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + offsets = offsets or [Coordinate.zero()] * len(use_channels) + flow_rates = flow_rates or [None] * len(use_channels) + liquid_height = liquid_height or [None] * len(use_channels) + blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) + + vols = [float(v) for v in vols] + flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] + liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] + blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] + + self._blow_out_air_volume = blow_out_air_volume + tips = [self.head[channel].get_tip() for channel in use_channels] + + for resource in resources: + if isinstance(resource.parent, Plate) and resource.parent.has_lid(): + raise ValueError("Aspirating from a well with a lid is not supported.") + + self._make_sure_channels_exist(use_channels) + for name, param in [ + ("resources", resources), + ("vols", vols), + ("offsets", offsets), + ("flow_rates", flow_rates), + ("liquid_height", liquid_height), + ("blow_out_air_volume", blow_out_air_volume), + ]: + if len(param) != len(use_channels): + raise ValueError( + f"Length of {name} must match use_channels: {len(param)} != {len(use_channels)}" + ) + + # spread channels across a single resource + if len(set(resources)) == 1: + resource = resources[0] + resources = [resource] * len(use_channels) + # Lazy import to avoid circular dependency between capabilities and legacy modules. + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets + + center_offsets = compute_channel_offsets( + resource=resource, num_channels=len(use_channels), spread=spread + ) + offsets = [c + o for c, o in zip(center_offsets, offsets)] + + aspirations = [ + Aspiration( + resource=r, + volume=v, + offset=o, + flow_rate=fr, + liquid_height=lh, + tip=t, + blow_out_air_volume=bav, + mix=m, + ) + for r, v, o, fr, lh, t, bav, m in zip( + resources, + vols, + offsets, + flow_rates, + liquid_height, + tips, + blow_out_air_volume, + mix or [None] * len(use_channels), # type: ignore + ) + ] + + # queue volume tracking + for op in aspirations: + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + op.resource.tracker.remove_liquid(op.volume) + op.tip.tracker.add_liquid(volume=op.volume) + + # execute + error: Optional[Exception] = None + try: + await self.backend.aspirate( + ops=aspirations, use_channels=use_channels, backend_params=backend_params + ) + except Exception as e: + error = e + + # determine per-channel success + successes = [error is None] * len(aspirations) + if error is not None and isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, aspirations, successes): + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + tip_volume_tracker = self.head[channel].get_tip().tracker + (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() + + if error is not None: + raise error + + @need_capability_ready + async def dispense( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified containers. + + Examples: + Dispense 50 uL to the first column: + + >>> await cap.dispense(plate["A1:H1"], vols=[50]*8) + + Args: + resources: Containers to dispense to. + vols: Volume to dispense per channel. + use_channels: Channels to use. Defaults to 0..len(resources)-1. + flow_rates: Flow rate per channel (ul/s). None = machine default. + offsets: Offset per channel. + liquid_height: Liquid height per channel (mm from bottom). None = machine default. + blow_out_air_volume: Air volume to dispense after liquid (ul). None = machine default. + spread: How to space channels on a single resource: "wide", "tight", or "custom". + mix: Mix parameters per channel. + backend_params: Vendor-specific parameters. + """ + + not_containers = [r for r in resources if not isinstance(r, Container)] + if not_containers: + raise TypeError(f"Resources must be Containers, got {not_containers}") + + use_channels = use_channels or self._default_use_channels or list(range(len(resources))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + offsets = offsets or [Coordinate.zero()] * len(use_channels) + flow_rates = flow_rates or [None] * len(use_channels) + liquid_height = liquid_height or [None] * len(use_channels) + blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) + + vols = [float(v) for v in vols] + flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] + liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] + blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] + + # spread channels across a single resource + if len(set(resources)) == 1: + resource = resources[0] + resources = [resource] * len(use_channels) + # Lazy import to avoid circular dependency between capabilities and legacy modules. + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets + + center_offsets = compute_channel_offsets( + resource=resource, num_channels=len(use_channels), spread=spread + ) + offsets = [c + o for c, o in zip(center_offsets, offsets)] + + tips = [self.head[channel].get_tip() for channel in use_channels] + + # check blow-out air volume against what was aspirated + if does_volume_tracking(): + if any(bav is not None and bav != 0.0 for bav in blow_out_air_volume): + if self._blow_out_air_volume is None: + raise BlowOutVolumeError("No blowout volume was aspirated.") + for requested_bav, done_bav in zip(blow_out_air_volume, self._blow_out_air_volume): + if requested_bav is not None and done_bav is not None and requested_bav > done_bav: + raise BlowOutVolumeError("Blowout volume is larger than aspirated volume") + + for resource in resources: + if isinstance(resource.parent, Plate) and resource.parent.has_lid(): + raise ValueError("Dispensing to a well with a lid is not supported.") + + for name, param in [ + ("resources", resources), + ("vols", vols), + ("offsets", offsets), + ("flow_rates", flow_rates), + ("liquid_height", liquid_height), + ("blow_out_air_volume", blow_out_air_volume), + ]: + if len(param) != len(use_channels): + raise ValueError( + f"Length of {name} must match use_channels: {len(param)} != {len(use_channels)}" + ) + + dispenses = [ + Dispense( + resource=r, + volume=v, + offset=o, + flow_rate=fr, + liquid_height=lh, + tip=t, + blow_out_air_volume=bav, + mix=m, + ) + for r, v, o, fr, lh, t, bav, m in zip( + resources, + vols, + offsets, + flow_rates, + liquid_height, + tips, + blow_out_air_volume, + mix or [None] * len(use_channels), # type: ignore + ) + ] + + # queue volume tracking + for op in dispenses: + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + op.resource.tracker.add_liquid(volume=op.volume) + op.tip.tracker.remove_liquid(op.volume) + + # execute + error: Optional[Exception] = None + try: + await self.backend.dispense( + ops=dispenses, use_channels=use_channels, backend_params=backend_params + ) + except Exception as e: + error = e + + # determine per-channel success + successes = [error is None] * len(dispenses) + if error is not None and isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, dispenses, successes): + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + tip_volume_tracker = self.head[channel].get_tip().tracker + (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() + + if any(bav is not None for bav in blow_out_air_volume): + self._blow_out_air_volume = None + + if error is not None: + raise error + + @need_capability_ready + async def transfer( + self, + source: Well, + targets: List[Well], + source_vol: Optional[float] = None, + ratios: Optional[List[float]] = None, + target_vols: Optional[List[float]] = None, + aspiration_flow_rate: Optional[float] = None, + dispense_flow_rates: Optional[List[Optional[float]]] = None, + aspirate_backend_params: Optional[BackendParams] = None, + dispense_backend_params: Optional[BackendParams] = None, + ): + """Transfer liquid from one well to multiple targets. + + Examples: + Transfer 50 uL from A1 to B1: + + >>> await cap.transfer(plate["A1"], plate["B1"], source_vol=50) + + Transfer 80 uL equally to the first column: + + >>> await cap.transfer(plate["A1"], plate["A1:H1"], source_vol=80) + + Transfer 60 uL in a 2:1 ratio: + + >>> await cap.transfer(plate["A1"], plate["B1:C1"], source_vol=60, ratios=[2, 1]) + + Args: + source: The source well. + targets: The target wells. + source_vol: The total volume to aspirate from source. + ratios: Ratios for distributing liquid. If None, distribute equally. + target_vols: Explicit volumes per target. Mutually exclusive with source_vol/ratios. + aspiration_flow_rate: Flow rate for aspiration (ul/s). + dispense_flow_rates: Flow rates for dispense per target (ul/s). + aspirate_backend_params: Vendor-specific parameters for aspiration. + dispense_backend_params: Vendor-specific parameters for dispense. + """ + + if target_vols is not None: + if ratios is not None: + raise TypeError("Cannot specify ratios and target_vols at the same time") + if source_vol is not None: + raise TypeError("Cannot specify source_vol and target_vols at the same time") + else: + if source_vol is None: + raise TypeError("Must specify either source_vol or target_vols") + if ratios is None: + ratios = [1] * len(targets) + target_vols = [source_vol * r / sum(ratios) for r in ratios] + + await self.aspirate( + resources=[source], + vols=[sum(target_vols)], + flow_rates=[aspiration_flow_rate], + backend_params=aspirate_backend_params, + ) + dispense_flow_rates = dispense_flow_rates or [None] * len(targets) + for target, vol, dfr in zip(targets, target_vols, dispense_flow_rates): + await self.dispense( + resources=[target], + vols=[vol], + flow_rates=[dfr], + use_channels=[0], + backend_params=dispense_backend_params, + ) + + @contextlib.contextmanager + def use_channels(self, channels: List[int]) -> Generator[None, None, None]: + """Temporarily use the specified channels as default for all operations. + + Examples: + >>> with cap.use_channels([2]): + ... await cap.pick_up_tips(tip_rack["A1"]) + ... await cap.aspirate(plate["A1"], vols=[50]) + """ + self._default_use_channels = channels + try: + yield + finally: + self._default_use_channels = None + + @contextlib.asynccontextmanager + async def use_tips( + self, + tip_spots: List[TipSpot], + trash: Trash, + channels: Optional[List[int]] = None, + discard: bool = True, + pick_up_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Context manager that picks up tips on entry and discards/returns on exit. + + Examples: + >>> async with cap.use_tips(tip_rack["A1":"H1"], trash=trash): + ... await cap.aspirate(plate["A1":"H1"], vols=[50]*8) + ... await cap.dispense(plate["A1":"H1"], vols=[50]*8) + """ + if channels is None: + channels = list(range(len(tip_spots))) + if len(tip_spots) != len(channels): + raise ValueError("Number of tip spots and channels must match.") + + await self.pick_up_tips(tip_spots, use_channels=channels, backend_params=pick_up_backend_params) + try: + yield + finally: + if discard: + await self.discard_tips( + trash=trash, use_channels=channels, drop_backend_params=drop_backend_params + ) + else: + await self.return_tips(use_channels=channels, drop_backend_params=drop_backend_params) diff --git a/pylabrobot/capabilities/liquid_handling/pip_backend.py b/pylabrobot/capabilities/liquid_handling/pip_backend.py new file mode 100644 index 00000000000..373db5371ba --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/pip_backend.py @@ -0,0 +1,76 @@ +"""Abstract backend for independent-channel liquid handling.""" + +from abc import ABCMeta, abstractmethod +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Tip + +from .standard import Aspiration, Dispense, Pickup, TipDrop + + +class PIPBackend(CapabilityBackend, metaclass=ABCMeta): + """Backend for independent-channel liquid handling operations. + + Each operation takes a list of ops (one per channel being used) and a list + of channel indices specifying which physical channels to use. + """ + + @property + @abstractmethod + def num_channels(self) -> int: + """The number of independent channels available.""" + + @abstractmethod + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from the specified tip spots.""" + + @abstractmethod + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to the specified resources.""" + + @abstractmethod + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified containers.""" + + @abstractmethod + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified containers.""" + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Check if the tip can be picked up by the specified channel. + + Does not consider if a tip is already mounted — just whether the tip is compatible. + Default returns True; override for hardware-specific constraints. + """ + return True + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Request the tip presence status for each channel. + + Returns a list of length `num_channels` where each element is True if a tip is mounted, + False if not, or None if unknown. + + Default raises NotImplementedError; override if hardware supports tip presence detection. + """ + raise NotImplementedError() diff --git a/pylabrobot/capabilities/liquid_handling/standard.py b/pylabrobot/capabilities/liquid_handling/standard.py new file mode 100644 index 00000000000..af006699664 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/standard.py @@ -0,0 +1,149 @@ +"""Standard types for liquid handling operations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Union + +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.resources import Container, Tip, TipRack, TipSpot, Trash, Well + + +@dataclass(frozen=True) +class Mix: + """Mix parameters for aspiration/dispense operations.""" + + volume: float + repetitions: int + flow_rate: float + + +# --------------------------------------------------------------------------- +# Independent channel operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Pickup: + """Pick up a tip from a tip spot.""" + + resource: TipSpot + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class TipDrop: + """Drop a tip to a tip spot or trash.""" + + resource: Union[TipSpot, Trash] + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class Aspiration: + """Aspirate liquid from a container using an independent channel.""" + + 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 Dispense: + """Dispense liquid to a container using an independent channel.""" + + 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] + + +# --------------------------------------------------------------------------- +# 96-head operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PickupTipRack: + """Pick up tips from a tip rack using the 96-head.""" + + resource: TipRack + offset: Coordinate + tips: Sequence[Optional[Tip]] + + +@dataclass(frozen=True) +class DropTipRack: + """Drop tips to a tip rack or trash using the 96-head.""" + + resource: Union[TipRack, Trash] + offset: Coordinate + + +@dataclass(frozen=True) +class MultiHeadAspirationPlate: + """Aspirate from wells in a plate using the 96-head.""" + + 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: + """Dispense to wells in a plate using the 96-head.""" + + 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: + """Aspirate from a single container (trough) using the 96-head.""" + + 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: + """Dispense to a single container (trough) using the 96-head.""" + + 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] diff --git a/pylabrobot/capabilities/liquid_handling/utils.py b/pylabrobot/capabilities/liquid_handling/utils.py new file mode 100644 index 00000000000..ace3173eaf0 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/utils.py @@ -0,0 +1,77 @@ +"""Utility functions for liquid handling channel spacing.""" + +from typing import List + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS = 9 +MIN_SPACING_BETWEEN_CHANNELS = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS +# minimum spacing between the edge of the container and the center of channel +MIN_SPACING_EDGE = 1.0 + + +def _get_centers_with_margin(dim_size: float, n: int, margin: float, min_spacing: float): + """Get the centers of the channels with a minimum margin on the edges.""" + if dim_size < margin * 2 + (n - 1) * min_spacing: + raise ValueError("Resource is too small to space channels.") + if dim_size - (n - 1) * min_spacing <= min_spacing * 2: + remaining_space = dim_size - (n - 1) * min_spacing - margin * 2 + return [margin + remaining_space / 2 + i * min_spacing for i in range(n)] + return [(i + 1) * dim_size / (n + 1) for i in range(n)] + + +def get_wide_single_resource_liquid_op_offsets( + resource: Resource, + num_channels: int, + min_spacing: float = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) -> List[Coordinate]: + resource_size = resource.get_absolute_size_y() + centers = list( + reversed( + _get_centers_with_margin( + dim_size=resource_size, + n=num_channels, + margin=MIN_SPACING_EDGE, + min_spacing=min_spacing, + ) + ) + ) # reverse because channels are from back to front + + # offsets are relative to the center of the resource, but above we computed them wrt lfb + # so we need to subtract the center of the resource + # also, offsets are in absolute space, so we need to rotate the center + return [ + Coordinate( + x=0, + y=c - resource.center().rotated(resource.get_absolute_rotation()).y, + z=0, + ) + for c in centers + ] + + +def get_tight_single_resource_liquid_op_offsets( + resource: Resource, + num_channels: int, + min_spacing: float = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) -> List[Coordinate]: + channel_space = (num_channels - 1) * min_spacing + + min_y = (resource.get_absolute_size_y() - channel_space) / 2 + if min_y < MIN_SPACING_EDGE: + raise ValueError("Resource is too small to space channels.") + + centers = [min_y + i * min_spacing for i in range(num_channels)][::-1] + + # offsets are relative to the center of the resource, but above we computed them wrt lfb + # so we need to subtract the center of the resource + # also, offsets are in absolute space, so we need to rotate the center + return [ + Coordinate( + x=0, + y=c - resource.center().rotated(resource.get_absolute_rotation()).y, + z=0, + ) + for c in centers + ] diff --git a/pylabrobot/capabilities/loading_tray/__init__.py b/pylabrobot/capabilities/loading_tray/__init__.py new file mode 100644 index 00000000000..cdbb1e380e3 --- /dev/null +++ b/pylabrobot/capabilities/loading_tray/__init__.py @@ -0,0 +1,3 @@ +from .backend import LoadingTrayBackend +from .has_loading_tray import HasLoadingTray +from .loading_tray import LoadingTray diff --git a/pylabrobot/capabilities/loading_tray/backend.py b/pylabrobot/capabilities/loading_tray/backend.py new file mode 100644 index 00000000000..802b695f22e --- /dev/null +++ b/pylabrobot/capabilities/loading_tray/backend.py @@ -0,0 +1,16 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend + + +class LoadingTrayBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for loading tray devices.""" + + @abstractmethod + async def open(self, backend_params: Optional[BackendParams] = None): + """Open the loading tray.""" + + @abstractmethod + async def close(self, backend_params: Optional[BackendParams] = None): + """Close the loading tray.""" diff --git a/pylabrobot/capabilities/loading_tray/chatterbox.py b/pylabrobot/capabilities/loading_tray/chatterbox.py new file mode 100644 index 00000000000..7f08b58505e --- /dev/null +++ b/pylabrobot/capabilities/loading_tray/chatterbox.py @@ -0,0 +1,18 @@ +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams + +from .backend import LoadingTrayBackend + +logger = logging.getLogger(__name__) + + +class LoadingTrayChatterboxBackend(LoadingTrayBackend): + """Chatterbox backend for device-free testing.""" + + async def open(self, backend_params: Optional[BackendParams] = None): + logger.info("Opening loading tray.") + + async def close(self, backend_params: Optional[BackendParams] = None): + logger.info("Closing loading tray.") diff --git a/pylabrobot/capabilities/loading_tray/has_loading_tray.py b/pylabrobot/capabilities/loading_tray/has_loading_tray.py new file mode 100644 index 00000000000..4abd40462b3 --- /dev/null +++ b/pylabrobot/capabilities/loading_tray/has_loading_tray.py @@ -0,0 +1,7 @@ +from .loading_tray import LoadingTray + + +class HasLoadingTray: + """Mixin for devices that have a loading tray.""" + + loading_tray: LoadingTray diff --git a/pylabrobot/capabilities/loading_tray/loading_tray.py b/pylabrobot/capabilities/loading_tray/loading_tray.py new file mode 100644 index 00000000000..63137a740a0 --- /dev/null +++ b/pylabrobot/capabilities/loading_tray/loading_tray.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource_holder import ResourceHolder + +from .backend import LoadingTrayBackend + + +class LoadingTray(Capability, ResourceHolder): + """Loading tray capability that can open/close and hold a resource.""" + + def __init__( + self, + backend: LoadingTrayBackend, + name: str, + size_x: float, + size_y: float, + size_z: float, + child_location: Coordinate = Coordinate.zero(), + category: str = "loading_tray", + model: Optional[str] = None, + ): + Capability.__init__(self, backend=backend) + 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.backend: LoadingTrayBackend = backend + + @need_capability_ready + async def open(self, backend_params: Optional[BackendParams] = None): + await self.backend.open(backend_params=backend_params) + + @need_capability_ready + async def close(self, backend_params: Optional[BackendParams] = None): + await self.backend.close(backend_params=backend_params) diff --git a/pylabrobot/capabilities/microscopy/__init__.py b/pylabrobot/capabilities/microscopy/__init__.py new file mode 100644 index 00000000000..64e321f977d --- /dev/null +++ b/pylabrobot/capabilities/microscopy/__init__.py @@ -0,0 +1,18 @@ +from .backend import MicroscopyBackend +from .microscopy import ( + Microscopy, + evaluate_focus_nvmg_sobel, + fraction_overexposed, + max_pixel_at_fraction, +) +from .standard import ( + AutoExposure, + AutoFocus, + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + ImagingResult, + Objective, +) diff --git a/pylabrobot/capabilities/microscopy/backend.py b/pylabrobot/capabilities/microscopy/backend.py new file mode 100644 index 00000000000..7c178bde190 --- /dev/null +++ b/pylabrobot/capabilities/microscopy/backend.py @@ -0,0 +1,47 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin + + +class MicroscopyBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for microscopy devices.""" + + @abstractmethod + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + """Capture an image at the specified well position. + + Args: + row: 0-indexed row of the well. + column: 0-indexed column of the well. + mode: Imaging mode (brightfield, fluorescence channel, etc.). + objective: Objective lens to use. + exposure_time: Exposure time in ms, or ``"machine-auto"`` for automatic. + focal_height: Focal height in mm, or ``"machine-auto"`` for automatic. + gain: Gain value, or ``"machine-auto"`` for automatic. + plate: The plate being imaged (used for geometry/labware parameters). + + Returns: + An :class:`ImagingResult` containing the captured image(s) and metadata. + """ diff --git a/pylabrobot/capabilities/microscopy/chatterbox.py b/pylabrobot/capabilities/microscopy/chatterbox.py new file mode 100644 index 00000000000..394395bf089 --- /dev/null +++ b/pylabrobot/capabilities/microscopy/chatterbox.py @@ -0,0 +1,49 @@ +from typing import Optional + +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin + +try: + import numpy as np # type: ignore + + HAS_NUMPY = True +except ImportError: + np = None # type: ignore[assignment] + HAS_NUMPY = False + + +class MicroscopyChatterboxBackend(MicroscopyBackend): + """Mock microscopy backend for testing.""" + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + if HAS_NUMPY: + assert np is not None + image = np.zeros((512, 512), dtype=np.uint16) + else: + image = [[0] * 512 for _ in range(512)] # type: ignore + + return ImagingResult( + images=[image], + exposure_time=exposure_time if isinstance(exposure_time, (int, float)) else 10.0, + focal_height=focal_height if isinstance(focal_height, (int, float)) else 0.0, + ) diff --git a/pylabrobot/capabilities/microscopy/microscopy.py b/pylabrobot/capabilities/microscopy/microscopy.py new file mode 100644 index 00000000000..16079fd4376 --- /dev/null +++ b/pylabrobot/capabilities/microscopy/microscopy.py @@ -0,0 +1,328 @@ +import logging +import math +import time +from typing import Dict, Optional, Tuple, Union, cast + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.microscopy.standard import ( + AutoExposure, + AutoFocus, + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .backend import MicroscopyBackend + +try: + import numpy as np # type: ignore + + HAS_NUMPY = True +except ImportError: + np = None # type: ignore[assignment] + HAS_NUMPY = False + +logger = logging.getLogger(__name__) + + +async def _golden_ratio_search(func, a: float, b: float, tol: float, timeout: float) -> float: + """Golden ratio search to maximize a unimodal function over [a, b].""" + phi = (1 + 5**0.5) / 2 + c = b - (b - a) / phi + d = a + (b - a) / phi + cache: Dict[float, float] = {} + + async def cached_func(x: float) -> float: + x = round(x / tol) * tol + if x not in cache: + cache[x] = await func(x) + return cache[x] + + t0 = time.time() + while abs(b - a) > tol: + if (await cached_func(c)) > (await cached_func(d)): + b = d + else: + a = c + c = b - (b - a) / phi + d = a + (b - a) / phi + if time.time() - t0 > timeout: + raise TimeoutError("Timeout while searching for optimal focus position") + + return (b + a) / 2 + + +class Microscopy(Capability): + """Microscopy imaging capability. + + Provides high-level image capture with support for auto-exposure and auto-focus. + + See :doc:`/user_guide/capabilities/microscopy` for a walkthrough. + """ + + def __init__(self, backend: MicroscopyBackend): + super().__init__(backend=backend) + self.backend: MicroscopyBackend = backend + + def _resolve_well(self, well: Union[Well, Tuple[int, int]]) -> Tuple[int, int]: + """Convert a Well or (row, col) tuple to (row, col) indices.""" + if isinstance(well, tuple): + return well + plate = cast(Plate, well.parent) + idx = plate.index_of_item(well) + if idx is None: + raise ValueError(f"Well {well} not found in plate {well.parent}") + row, column = divmod(idx, plate.num_items_x) + return row, column + + async def _capture_auto_exposure( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + auto_exposure: AutoExposure, + focal_height: float, + gain: float, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + """Capture with iterative auto-exposure using weighted binary search.""" + + def _rms_split(low: float, high: float) -> float: + if low == high: + return low + return math.sqrt((low**2 + high**2) / 2) + + low, high = auto_exposure.low, auto_exposure.high + rounds = 0 + while high - low > 1e-3: + if auto_exposure.max_rounds is not None and rounds >= auto_exposure.max_rounds: + raise ValueError("Exceeded maximum number of auto-exposure rounds") + rounds += 1 + + p = _rms_split(low, high) + res = await self.capture( + well=well, + mode=mode, + objective=objective, + exposure_time=p, + focal_height=focal_height, + gain=gain, + plate=plate, + backend_params=backend_params, + ) + assert len(res.images) == 1, "Expected exactly one image for auto-exposure" + evaluation = await auto_exposure.evaluate_exposure(res.images[0]) + + if evaluation == "good": + return res + if evaluation == "lower": + high = p + elif evaluation == "higher": + low = p + else: + raise ValueError(f"Unexpected evaluation result: {evaluation}") + + raise RuntimeError("Failed to find a good exposure time.") + + async def _capture_auto_focus( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + exposure_time: float, + auto_focus: AutoFocus, + gain: float, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + """Capture with golden-ratio auto-focus search.""" + + async def local_capture(focal_height: float) -> ImagingResult: + return await self.capture( + well=well, + mode=mode, + objective=objective, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + backend_params=backend_params, + ) + + async def capture_and_evaluate(focal_height: float) -> float: + res = await local_capture(focal_height) + return auto_focus.evaluate_focus(res.images[0]) + + best_focal_height = await _golden_ratio_search( + func=capture_and_evaluate, + a=auto_focus.low, + b=auto_focus.high, + tol=auto_focus.tolerance, + timeout=auto_focus.timeout, + ) + return await local_capture(best_focal_height) + + @need_capability_ready + async def capture( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + plate: Plate, + exposure_time: Union[Exposure, AutoExposure] = "machine-auto", + focal_height: Union[FocalPosition, AutoFocus] = "machine-auto", + gain: Gain = "machine-auto", + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + """Capture an image of a well. + + Args: + well: A :class:`Well` instance or a ``(row, column)`` tuple (0-indexed). + mode: Imaging mode (brightfield, fluorescence channel, etc.). + objective: Objective lens to use. + plate: The plate being imaged. + exposure_time: Exposure time in ms, :class:`AutoExposure`, or ``"machine-auto"``. + focal_height: Focal height in mm, :class:`AutoFocus`, or ``"machine-auto"``. + gain: Gain value or ``"machine-auto"``. + backend_params: Backend-specific parameters. + + Returns: + An :class:`ImagingResult` with captured image(s) and metadata. + """ + if isinstance(exposure_time, AutoExposure): + if not isinstance(focal_height, (int, float)): + raise ValueError("Focal height must be a number when using AutoExposure") + if not isinstance(gain, (int, float)): + raise ValueError("Gain must be a number when using AutoExposure") + return await self._capture_auto_exposure( + well=well, + mode=mode, + objective=objective, + auto_exposure=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + backend_params=backend_params, + ) + + if isinstance(focal_height, AutoFocus): + if not isinstance(exposure_time, (int, float)): + raise ValueError("Exposure time must be a number when using AutoFocus") + if not isinstance(gain, (int, float)): + raise ValueError("Gain must be a number when using AutoFocus") + return await self._capture_auto_focus( + well=well, + mode=mode, + objective=objective, + exposure_time=exposure_time, + auto_focus=focal_height, + gain=gain, + plate=plate, + backend_params=backend_params, + ) + + row, column = self._resolve_well(well) + return await self.backend.capture( + row=row, + column=column, + mode=mode, + objective=objective, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + backend_params=backend_params, + ) + + async def _on_stop(self): + await super()._on_stop() + + +# --------------------------------------------------------------------------- +# Exposure / focus evaluation helpers +# --------------------------------------------------------------------------- + +try: + import cv2 as _cv2 # type: ignore + + _CV2_AVAILABLE = True +except ImportError as _e: + _cv2 = None # type: ignore + _CV2_AVAILABLE = False + _CV2_IMPORT_ERROR = _e + + +def max_pixel_at_fraction(fraction: float, margin: float): + """Return an evaluate_exposure callback targeting *fraction* of max pixel value. + + Args: + fraction: desired ratio of actual max pixel to theoretical max (e.g. 0.8). + margin: acceptable error as a fraction of theoretical max (e.g. 0.05). + """ + if np is None: + raise ImportError("numpy is required for max_pixel_at_fraction") + + async def evaluate_exposure(im): + array = np.array(im, dtype=np.float32) + value = np.max(array) - (255.0 * fraction) + margin_value = 255.0 * margin + if abs(value) <= margin_value: + return "good" + return "lower" if value > 0 else "higher" + + return evaluate_exposure + + +def fraction_overexposed(fraction: float, margin: float, max_pixel_value: int = 255): + """Return an evaluate_exposure callback targeting a fraction of saturated pixels. + + Args: + fraction: desired fraction of overexposed pixels (e.g. 0.005). + margin: acceptable error on that fraction (e.g. 0.001). + max_pixel_value: threshold for "overexposed" (default 255). + """ + if np is None: + raise ImportError("numpy is required for fraction_overexposed") + + async def evaluate_exposure(im): + arr = np.asarray(im, dtype=np.uint8) + actual_fraction = np.count_nonzero(arr > max_pixel_value) / arr.size + lower_bound, upper_bound = fraction - margin, fraction + margin + if lower_bound <= actual_fraction <= upper_bound: + return "good" + return "lower" if (actual_fraction - fraction) > 0 else "higher" + + return evaluate_exposure + + +def evaluate_focus_nvmg_sobel(image) -> float: + """Evaluate focus via Normalized Variance of Gradient Magnitude (Sobel). + + Uses the center 50 % of the image to avoid edge effects. + """ + if not _CV2_AVAILABLE: + raise RuntimeError( + f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}" + ) + if np is None: + raise ImportError("numpy is required for evaluate_focus_nvmg_sobel") + + np_image = np.array(image, dtype=np.float64) + height, width = np_image.shape[:2] + crop_h, crop_w = height // 4, width // 4 + np_image = np_image[crop_h : height - crop_h, crop_w : width - crop_w] + + sobel_x = _cv2.Sobel(np_image, _cv2.CV_64F, 1, 0, ksize=3) + sobel_y = _cv2.Sobel(np_image, _cv2.CV_64F, 0, 1, ksize=3) + gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2) + + mean_gm = np.mean(gradient_magnitude) + var_gm = np.var(gradient_magnitude) + return float(var_gm / (mean_gm + 1e-6)) diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py new file mode 100644 index 00000000000..d6faf9e4d0b --- /dev/null +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -0,0 +1,175 @@ +"""Tests for Microscopy.""" + +import unittest +from typing import List, Optional, Tuple + +import pytest + +pytest.importorskip("numpy") + +import numpy # noqa: E402 + +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.chatterbox import MicroscopyChatterboxBackend +from pylabrobot.capabilities.microscopy.microscopy import Microscopy +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin + + +def _test_plate() -> Plate: + 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, + ), + ) + + +class RecordingMicroscopyBackend(MicroscopyBackend): + """Backend that records all capture calls for assertion.""" + + def __init__(self): + self.calls: List[Tuple] = [] + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + self.calls.append((row, column, mode, objective, exposure_time, focal_height, gain)) + return ImagingResult( + images=[numpy.zeros((4, 4), dtype=int)], + exposure_time=exposure_time if isinstance(exposure_time, (int, float)) else 10.0, + focal_height=focal_height if isinstance(focal_height, (int, float)) else 0.0, + ) + + +class TestMicroscopy(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingMicroscopyBackend() + self.cap = Microscopy(backend=self.backend) + await self.cap._on_setup() + self.plate = _test_plate() + + async def test_capture_with_tuple_well(self): + result = await self.cap.capture( + well=(2, 5), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=self.plate, + exposure_time=10.0, + focal_height=1.5, + gain=1.0, + ) + self.assertEqual(len(self.backend.calls), 1) + row, col, mode, obj, exp, fh, g = self.backend.calls[0] + self.assertEqual(row, 2) + self.assertEqual(col, 5) + self.assertEqual(mode, ImagingMode.BRIGHTFIELD) + self.assertEqual(obj, Objective.O_10X_PL_FL) + self.assertAlmostEqual(exp, 10.0) + self.assertAlmostEqual(fh, 1.5) + self.assertAlmostEqual(g, 1.0) + self.assertEqual(len(result.images), 1) + + async def test_capture_with_well_object(self): + well = self.plate.get_well("C7") + await self.cap.capture( + well=well, + mode=ImagingMode.DAPI, + objective=Objective.O_20X_PL_FL, + plate=self.plate, + exposure_time=5.0, + focal_height=2.0, + gain=0.5, + ) + self.assertEqual(len(self.backend.calls), 1) + row, col, *_ = self.backend.calls[0] + # index_of_item for C7 = 50, divmod(50, 12) = (4, 2) + expected_idx = self.plate.index_of_item(well) + assert expected_idx is not None + expected_row, expected_col = divmod(expected_idx, self.plate.num_items_x) + self.assertEqual(row, expected_row) + self.assertEqual(col, expected_col) + + async def test_capture_machine_auto(self): + await self.cap.capture( + well=(0, 0), + mode=ImagingMode.GFP, + objective=Objective.O_4X_PL_FL, + plate=self.plate, + ) + self.assertEqual(len(self.backend.calls), 1) + _, _, _, _, exp, fh, g = self.backend.calls[0] + self.assertEqual(exp, "machine-auto") + self.assertEqual(fh, "machine-auto") + self.assertEqual(g, "machine-auto") + + async def test_capture_requires_setup(self): + backend = RecordingMicroscopyBackend() + cap = Microscopy(backend=backend) + with self.assertRaises(RuntimeError): + await cap.capture( + well=(0, 0), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_4X_PL_FL, + plate=self.plate, + ) + + +class TestChatterboxBackend(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_capture(self): + backend = MicroscopyChatterboxBackend() + cap = Microscopy(backend=backend) + await cap._on_setup() + + plate = _test_plate() + result = await cap.capture( + well=(0, 0), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_4X_PL_FL, + plate=plate, + exposure_time=10.0, + focal_height=1.0, + gain=1.0, + ) + self.assertEqual(len(result.images), 1) + self.assertAlmostEqual(result.exposure_time, 10.0) + self.assertAlmostEqual(result.focal_height, 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/microscopy/standard.py b/pylabrobot/capabilities/microscopy/standard.py new file mode 100644 index 00000000000..284184432bb --- /dev/null +++ b/pylabrobot/capabilities/microscopy/standard.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/capabilities/peeling/__init__.py b/pylabrobot/capabilities/peeling/__init__.py new file mode 100644 index 00000000000..663bab1f45c --- /dev/null +++ b/pylabrobot/capabilities/peeling/__init__.py @@ -0,0 +1,2 @@ +from .backend import PeelerBackend +from .peeling import Peeler diff --git a/pylabrobot/capabilities/peeling/backend.py b/pylabrobot/capabilities/peeling/backend.py new file mode 100644 index 00000000000..e3a64bc6de6 --- /dev/null +++ b/pylabrobot/capabilities/peeling/backend.py @@ -0,0 +1,17 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.serializer import SerializableMixin + + +class PeelerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for peeling devices.""" + + @abstractmethod + async def peel(self, backend_params: Optional[SerializableMixin] = None): + """Run an automated de-seal cycle.""" + + @abstractmethod + async def restart(self, backend_params: Optional[SerializableMixin] = None): + """Restart the peeler machine.""" diff --git a/pylabrobot/capabilities/peeling/chatterbox.py b/pylabrobot/capabilities/peeling/chatterbox.py new file mode 100644 index 00000000000..dcdd3381b7c --- /dev/null +++ b/pylabrobot/capabilities/peeling/chatterbox.py @@ -0,0 +1,18 @@ +import logging +from typing import Optional + +from pylabrobot.serializer import SerializableMixin + +from .backend import PeelerBackend + +logger = logging.getLogger(__name__) + + +class PeelerChatterboxBackend(PeelerBackend): + """Chatterbox backend for device-free testing.""" + + async def peel(self, backend_params: Optional[SerializableMixin] = None): + logger.info("Running peel cycle.") + + async def restart(self, backend_params: Optional[SerializableMixin] = None): + logger.info("Restarting peeler.") diff --git a/pylabrobot/capabilities/peeling/peeling.py b/pylabrobot/capabilities/peeling/peeling.py new file mode 100644 index 00000000000..e3fa9c63562 --- /dev/null +++ b/pylabrobot/capabilities/peeling/peeling.py @@ -0,0 +1,30 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.serializer import SerializableMixin + +from .backend import PeelerBackend + + +class Peeler(Capability): + """Peeling capability. + + See :doc:`/user_guide/capabilities/peeling` for a walkthrough. + """ + + def __init__(self, backend: PeelerBackend): + super().__init__(backend=backend) + self.backend: PeelerBackend = backend + + @need_capability_ready + async def peel(self, backend_params: Optional[SerializableMixin] = None): + """Run an automated de-seal cycle.""" + return await self.backend.peel(backend_params=backend_params) + + @need_capability_ready + async def restart(self, backend_params: Optional[SerializableMixin] = None): + """Restart the peeler.""" + return await self.backend.restart(backend_params=backend_params) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/plate_reading/__init__.py b/pylabrobot/capabilities/plate_reading/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/__init__.py @@ -0,0 +1 @@ + diff --git a/pylabrobot/capabilities/plate_reading/absorbance/__init__.py b/pylabrobot/capabilities/plate_reading/absorbance/__init__.py new file mode 100644 index 00000000000..475da20e46f --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/__init__.py @@ -0,0 +1,3 @@ +from .absorbance import Absorbance +from .backend import AbsorbanceBackend +from .standard import AbsorbanceResult diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py new file mode 100644 index 00000000000..6ae2cc5dfda --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .backend import AbsorbanceBackend + +logger = logging.getLogger(__name__) + + +class Absorbance(Capability): + """Absorbance plate reading capability. + + See :doc:`/user_guide/capabilities/absorbance` for a walkthrough. + """ + + def __init__(self, backend: AbsorbanceBackend): + super().__init__(backend=backend) + self.backend: AbsorbanceBackend = backend + + @need_capability_ready + async def read( + self, + plate: Plate, + wavelength: int, + wells: Optional[List[Well]] = None, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + """Read absorbance from a plate. + + Args: + plate: The plate to read. + wavelength: Wavelength in nm. + wells: Wells to measure. Defaults to all wells in the plate. + backend_params: Backend-specific parameters. + + Returns: + A list of :class:`AbsorbanceResult` (typically length 1). + """ + if wells is None: + wells = plate.get_all_items() + return await self.backend.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, backend_params=backend_params + ) diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py new file mode 100644 index 00000000000..9e4d2c20abb --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -0,0 +1,115 @@ +"""Tests for Absorbance.""" + +import unittest +from typing import List, Optional, Tuple + +from pylabrobot.capabilities.plate_reading.absorbance.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import ( + AbsorbanceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin + + +def _test_plate() -> Plate: + 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, + ), + ) + + +class RecordingAbsorbanceBackend(AbsorbanceBackend): + """Backend that records all read_absorbance calls for assertion.""" + + def __init__(self): + self.calls: List[Tuple] = [] + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + self.calls.append((plate, wells, wavelength)) + data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for well in wells: + r, c = well.get_row(), well.get_column() + data[r][c] = 0.5 + return [AbsorbanceResult(data=data, wavelength=wavelength, temperature=None, timestamp=0.0)] + + +class TestAbsorbance(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingAbsorbanceBackend() + self.cap = Absorbance(backend=self.backend) + await self.cap._on_setup() + self.plate = _test_plate() + + async def test_read_with_wells(self): + wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] + results = await self.cap.read(plate=self.plate, wavelength=450, wells=wells) + self.assertEqual(len(self.backend.calls), 1) + _, recorded_wells, recorded_wl = self.backend.calls[0] + self.assertEqual(recorded_wells, wells) + self.assertEqual(recorded_wl, 450) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].wavelength, 450) + + async def test_read_all_wells(self): + results = await self.cap.read(plate=self.plate, wavelength=600) + self.assertEqual(len(self.backend.calls), 1) + _, recorded_wells, _ = self.backend.calls[0] + self.assertEqual(len(recorded_wells), 96) + self.assertEqual(results[0].wavelength, 600) + + async def test_read_requires_setup(self): + backend = RecordingAbsorbanceBackend() + cap = Absorbance(backend=backend) + with self.assertRaises(RuntimeError): + await cap.read(plate=self.plate, wavelength=450) + + +class TestAbsorbanceChatterbox(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_read(self): + backend = AbsorbanceChatterboxBackend() + cap = Absorbance(backend=backend) + await cap._on_setup() + + plate = _test_plate() + wells = [plate.get_well("A1"), plate.get_well("H12")] + results = await cap.read(plate=plate, wavelength=450, wells=wells) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].wavelength, 450) + # Only requested wells should have data + self.assertIsNotNone(results[0].data[0][0]) # A1 + self.assertIsNotNone(results[0].data[7][11]) # H12 + self.assertIsNone(results[0].data[0][1]) # A2 not requested + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/backend.py b/pylabrobot/capabilities/plate_reading/absorbance/backend.py new file mode 100644 index 00000000000..48c061456ff --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/backend.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List, Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + + +class AbsorbanceBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for absorbance plate reading.""" + + @abstractmethod + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + """Read absorbance for the given wells. + + Args: + plate: The plate to read. + wells: Wells to measure. + wavelength: Wavelength in nm. + + Returns: + A list of :class:`AbsorbanceResult` (typically length 1). + """ diff --git a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py new file mode 100644 index 00000000000..b89f88345c7 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py @@ -0,0 +1,33 @@ +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.plate_reading.utils import mask_wells +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + + +class AbsorbanceChatterboxBackend(AbsorbanceBackend): + """Mock absorbance backend for testing.""" + + def __init__(self): + self.dummy_absorbance: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + data = mask_wells(self.dummy_absorbance, wells, plate) + return [ + AbsorbanceResult( + data=data, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/plate_reading/absorbance/standard.py b/pylabrobot/capabilities/plate_reading/absorbance/standard.py new file mode 100644 index 00000000000..c1eda696f34 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/standard.py @@ -0,0 +1,21 @@ +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 °C, or ``None`` if not available. + timestamp: Unix timestamp of the measurement. + """ + + data: List[List[Optional[float]]] + wavelength: int + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py b/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py new file mode 100644 index 00000000000..490739cbea4 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py @@ -0,0 +1,3 @@ +from .backend import FluorescenceBackend +from .fluorescence import Fluorescence +from .standard import FluorescenceResult diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py new file mode 100644 index 00000000000..ae9eb69e126 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List, Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + + +class FluorescenceBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for fluorescence plate reading.""" + + @abstractmethod + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + """Read fluorescence for the given wells. + + Args: + plate: The plate to read. + wells: Wells to measure. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + focal_height: Focal height in mm. + + Returns: + A list of :class:`FluorescenceResult` (typically length 1). + """ diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py new file mode 100644 index 00000000000..28183535b35 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py @@ -0,0 +1,36 @@ +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.utils import mask_wells +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + + +class FluorescenceChatterboxBackend(FluorescenceBackend): + """Mock fluorescence backend for testing.""" + + def __init__(self): + self.dummy_fluorescence: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + data = mask_wells(self.dummy_fluorescence, wells, plate) + return [ + FluorescenceResult( + data=data, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py new file mode 100644 index 00000000000..18e87a773fb --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .backend import FluorescenceBackend + +logger = logging.getLogger(__name__) + + +class Fluorescence(Capability): + """Fluorescence plate reading capability. + + See :doc:`/user_guide/capabilities/fluorescence` for a walkthrough. + """ + + def __init__(self, backend: FluorescenceBackend): + super().__init__(backend=backend) + self.backend: FluorescenceBackend = backend + + @need_capability_ready + async def read( + self, + plate: Plate, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + wells: Optional[List[Well]] = None, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + """Read fluorescence from a plate. + + Args: + plate: The plate to read. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + focal_height: Focal height in mm. + wells: Wells to measure. Defaults to all wells in the plate. + backend_params: Backend-specific parameters. + + Returns: + A list of :class:`FluorescenceResult` (typically length 1). + """ + if wells is None: + wells = plate.get_all_items() + return await self.backend.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + backend_params=backend_params, + ) diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py new file mode 100644 index 00000000000..2067438e461 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -0,0 +1,147 @@ +"""Tests for Fluorescence.""" + +import unittest +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import ( + FluorescenceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.fluorescence.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin + + +def _test_plate() -> Plate: + 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, + ), + ) + + +class RecordingFluorescenceBackend(FluorescenceBackend): + """Backend that records all calls for assertion.""" + + def __init__(self): + self.calls: List[tuple] = [] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + self.calls.append( + ("read_fluorescence", len(wells), excitation_wavelength, emission_wavelength, focal_height) + ) + data: List[List[Optional[float]]] = [ + [0.0] * plate.num_items_x for _ in range(plate.num_items_y) + ] + return [ + FluorescenceResult( + data=data, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + temperature=25.0, + timestamp=0.0, + ) + ] + + +class TestFluorescence(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingFluorescenceBackend() + self.cap = Fluorescence(backend=self.backend) + await self.cap._on_setup() + self.plate = _test_plate() + + async def test_read_with_wells(self): + wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] + results = await self.cap.read( + plate=self.plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + wells=wells, + ) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 528) + self.assertEqual(len(self.backend.calls), 1) + _, n_wells, ex_wl, em_wl, fh = self.backend.calls[0] + self.assertEqual(n_wells, 2) + self.assertEqual(ex_wl, 485) + self.assertEqual(em_wl, 528) + self.assertAlmostEqual(fh, 8.5) + + async def test_read_all_wells(self): + results = await self.cap.read( + plate=self.plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + ) + self.assertEqual(len(results), 1) + _, n_wells, *_ = self.backend.calls[0] + self.assertEqual(n_wells, 96) + + async def test_read_requires_setup(self): + backend = RecordingFluorescenceBackend() + cap = Fluorescence(backend=backend) + with self.assertRaises(RuntimeError): + await cap.read( + plate=self.plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + ) + + +class TestFluorescenceChatterbox(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_read(self): + backend = FluorescenceChatterboxBackend() + cap = Fluorescence(backend=backend) + await cap._on_setup() + plate = _test_plate() + + wells = [plate.get_well("A1"), plate.get_well("C3")] + results = await cap.read( + plate=plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + wells=wells, + ) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 528) + self.assertEqual(results[0].data[0][0], 0.0) + self.assertIsNone(results[0].data[1][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/standard.py b/pylabrobot/capabilities/plate_reading/fluorescence/standard.py new file mode 100644 index 00000000000..e741c26effa --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/standard.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import dataclasses +from typing import List, Optional + + +@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: Unix timestamp of the measurement. + """ + + data: List[List[Optional[float]]] + excitation_wavelength: int + emission_wavelength: int + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/capabilities/plate_reading/luminescence/__init__.py b/pylabrobot/capabilities/plate_reading/luminescence/__init__.py new file mode 100644 index 00000000000..ec374ef7ec2 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/__init__.py @@ -0,0 +1,3 @@ +from .backend import LuminescenceBackend +from .luminescence import Luminescence +from .standard import LuminescenceResult diff --git a/pylabrobot/capabilities/plate_reading/luminescence/backend.py b/pylabrobot/capabilities/plate_reading/luminescence/backend.py new file mode 100644 index 00000000000..2090b0fd77d --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/backend.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List, Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + + +class LuminescenceBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for luminescence plate reading.""" + + @abstractmethod + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + """Read luminescence for the given wells. + + Args: + plate: The plate to read. + wells: Wells to measure. + focal_height: Focal height in mm. + + Returns: + A list of :class:`LuminescenceResult` (typically length 1). + """ diff --git a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py new file mode 100644 index 00000000000..6823337a88d --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py @@ -0,0 +1,32 @@ +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.capabilities.plate_reading.utils import mask_wells +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + + +class LuminescenceChatterboxBackend(LuminescenceBackend): + """Mock luminescence backend for testing.""" + + def __init__(self): + self.dummy_luminescence: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + data = mask_wells(self.dummy_luminescence, wells, plate) + return [ + LuminescenceResult( + data=data, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py new file mode 100644 index 00000000000..d17dfd64ad9 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .backend import LuminescenceBackend + +logger = logging.getLogger(__name__) + + +class Luminescence(Capability): + """Luminescence plate reading capability. + + See :doc:`/user_guide/capabilities/luminescence` for a walkthrough. + """ + + def __init__(self, backend: LuminescenceBackend): + super().__init__(backend=backend) + self.backend: LuminescenceBackend = backend + + @need_capability_ready + async def read( + self, + plate: Plate, + focal_height: float, + wells: Optional[List[Well]] = None, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + """Read luminescence from a plate. + + Args: + plate: The plate to read. + focal_height: Focal height in mm. + wells: Wells to measure. Defaults to all wells in the plate. + backend_params: Backend-specific parameters. + + Returns: + A list of :class:`LuminescenceResult` (typically length 1). + """ + if wells is None: + wells = plate.get_all_items() + return await self.backend.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, backend_params=backend_params + ) diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py new file mode 100644 index 00000000000..342858aef3a --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -0,0 +1,109 @@ +"""Tests for Luminescence.""" + +import unittest +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import ( + LuminescenceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.luminescence.luminescence import Luminescence +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin + + +def _test_plate() -> Plate: + 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, + ), + ) + + +class RecordingLuminescenceBackend(LuminescenceBackend): + """Backend that records all calls for assertion.""" + + def __init__(self): + self.calls: List[tuple] = [] + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + self.calls.append(("read_luminescence", len(wells), focal_height)) + data: List[List[Optional[float]]] = [ + [0.0] * plate.num_items_x for _ in range(plate.num_items_y) + ] + return [LuminescenceResult(data=data, temperature=25.0, timestamp=0.0)] + + +class TestLuminescence(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingLuminescenceBackend() + self.cap = Luminescence(backend=self.backend) + await self.cap._on_setup() + self.plate = _test_plate() + + async def test_read_with_wells(self): + wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] + results = await self.cap.read(plate=self.plate, focal_height=13.0, wells=wells) + self.assertEqual(len(results), 1) + self.assertEqual(len(self.backend.calls), 1) + _, n_wells, fh = self.backend.calls[0] + self.assertEqual(n_wells, 2) + self.assertAlmostEqual(fh, 13.0) + + async def test_read_all_wells(self): + results = await self.cap.read(plate=self.plate, focal_height=13.0) + self.assertEqual(len(results), 1) + _, n_wells, _ = self.backend.calls[0] + self.assertEqual(n_wells, 96) + + async def test_read_requires_setup(self): + backend = RecordingLuminescenceBackend() + cap = Luminescence(backend=backend) + with self.assertRaises(RuntimeError): + await cap.read(plate=self.plate, focal_height=13.0) + + +class TestLuminescenceChatterbox(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_read(self): + backend = LuminescenceChatterboxBackend() + cap = Luminescence(backend=backend) + await cap._on_setup() + plate = _test_plate() + + wells = [plate.get_well("A1"), plate.get_well("C3")] + results = await cap.read(plate=plate, focal_height=13.0, wells=wells) + self.assertEqual(len(results), 1) + # A1 = row 0, col 0 => measured + self.assertEqual(results[0].data[0][0], 0.0) + # B1 = row 1, col 0 => not measured + self.assertIsNone(results[0].data[1][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/luminescence/standard.py b/pylabrobot/capabilities/plate_reading/luminescence/standard.py new file mode 100644 index 00000000000..36cf8376d08 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/standard.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import dataclasses +from typing import List, Optional + + +@dataclasses.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: Unix timestamp of the measurement. + """ + + data: List[List[Optional[float]]] + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/capabilities/plate_reading/utils.py b/pylabrobot/capabilities/plate_reading/utils.py new file mode 100644 index 00000000000..c20bb2a363a --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/utils.py @@ -0,0 +1,18 @@ +from typing import List, Optional + +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +def mask_wells( + result: List[List[Optional[float]]], wells: List[Well], plate: Plate +) -> List[List[Optional[float]]]: + """Return a copy of *result* with only the requested wells; others become ``None``.""" + masked: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for well in wells: + r, c = well.get_row(), well.get_column() + if r < plate.num_items_y and c < plate.num_items_x: + masked[r][c] = result[r][c] + return masked diff --git a/pylabrobot/capabilities/plate_washing/__init__.py b/pylabrobot/capabilities/plate_washing/__init__.py new file mode 100644 index 00000000000..f65bd1202a4 --- /dev/null +++ b/pylabrobot/capabilities/plate_washing/__init__.py @@ -0,0 +1,2 @@ +from .backend import PlateWasher96Backend +from .plate_washing import PlateWasher96 diff --git a/pylabrobot/capabilities/plate_washing/backend.py b/pylabrobot/capabilities/plate_washing/backend.py new file mode 100644 index 00000000000..0a8efc83618 --- /dev/null +++ b/pylabrobot/capabilities/plate_washing/backend.py @@ -0,0 +1,67 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Plate + + +class PlateWasher96Backend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for plate washing devices.""" + + @abstractmethod + async def aspirate( + self, + plate: Plate, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Aspirate (remove) liquid from all wells. + + Args: + plate: Target plate. + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def dispense( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid into all wells. + + Args: + plate: Target plate. + volume: Volume per well in uL. + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def wash( + self, + plate: Plate, + cycles: int = 3, + dispense_volume: Optional[float] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Perform wash cycles (repeated dispense + aspirate). + + Args: + plate: Target plate. + cycles: Number of wash cycles. + dispense_volume: Volume per well per cycle in uL. If None, use device default. + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def prime( + self, + plate: Plate, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime fluid lines. + + Args: + plate: Target plate. + backend_params: Backend-specific parameters. + """ diff --git a/pylabrobot/capabilities/plate_washing/plate_washing.py b/pylabrobot/capabilities/plate_washing/plate_washing.py new file mode 100644 index 00000000000..1eb6b41457e --- /dev/null +++ b/pylabrobot/capabilities/plate_washing/plate_washing.py @@ -0,0 +1,78 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import Plate + +from .backend import PlateWasher96Backend + + +class PlateWasher96(Capability): + """Plate washing capability.""" + + def __init__(self, backend: PlateWasher96Backend): + super().__init__(backend=backend) + self.backend: PlateWasher96Backend = backend + self._plate: Optional[Plate] = None + + @property + def plate(self) -> Plate: + if self._plate is None: + raise RuntimeError("No plate assigned to this capability.") + return self._plate + + @plate.setter + def plate(self, value: Optional[Plate]): + if value is not None and self._plate is not None: + raise RuntimeError(f"A plate is already assigned ({self._plate.name}). Unassign it first.") + self._plate = value + + @need_capability_ready + async def aspirate( + self, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Aspirate (remove) liquid from all wells.""" + await self.backend.aspirate(plate=self.plate, backend_params=backend_params) + + @need_capability_ready + async def dispense( + self, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid into all wells. + + Args: + volume: Volume per well in uL. + backend_params: Backend-specific parameters. + """ + await self.backend.dispense(plate=self.plate, volume=volume, backend_params=backend_params) + + @need_capability_ready + async def wash( + self, + cycles: int = 3, + dispense_volume: Optional[float] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Perform wash cycles (repeated dispense + aspirate). + + Args: + cycles: Number of wash cycles. + dispense_volume: Volume per well per cycle in uL. If None, use device default. + backend_params: Backend-specific parameters. + """ + await self.backend.wash( + plate=self.plate, + cycles=cycles, + dispense_volume=dispense_volume, + backend_params=backend_params, + ) + + @need_capability_ready + async def prime( + self, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime fluid lines.""" + await self.backend.prime(plate=self.plate, backend_params=backend_params) diff --git a/pylabrobot/capabilities/pumping/__init__.py b/pylabrobot/capabilities/pumping/__init__.py new file mode 100644 index 00000000000..6c2f36bcc9b --- /dev/null +++ b/pylabrobot/capabilities/pumping/__init__.py @@ -0,0 +1,5 @@ +from .backend import PumpBackend +from .calibration import PumpCalibration +from .chatterbox import PumpChatterboxBackend +from .errors import NotCalibratedError +from .pumping import Pump diff --git a/pylabrobot/capabilities/pumping/backend.py b/pylabrobot/capabilities/pumping/backend.py new file mode 100644 index 00000000000..467d22977ce --- /dev/null +++ b/pylabrobot/capabilities/pumping/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class PumpBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for a single pump.""" + + @abstractmethod + async def run_revolutions(self, num_revolutions: float): + """Run for a given number of revolutions.""" + + @abstractmethod + async def run_continuously(self, speed: float): + """Run continuously at a given speed. If speed is 0, halt.""" + + @abstractmethod + async def halt(self): + """Halt the pump.""" diff --git a/pylabrobot/pumps/calibration.py b/pylabrobot/capabilities/pumping/calibration.py similarity index 72% rename from pylabrobot/pumps/calibration.py rename to pylabrobot/capabilities/pumping/calibration.py index 48a25afb6e8..a94d9dba25c 100644 --- a/pylabrobot/pumps/calibration.py +++ b/pylabrobot/capabilities/pumping/calibration.py @@ -31,7 +31,7 @@ def __init__( """ if any(value <= 0 for value in calibration): - raise ValueError("A value in the calibration is is outside expected parameters.") + raise ValueError("A value in the calibration is outside expected parameters.") if calibration_mode not in ["duration", "revolutions"]: raise ValueError("calibration_mode must be 'duration' or 'revolutions'") self.calibration = calibration @@ -42,7 +42,6 @@ def __getitem__(self, item: int) -> Union[float, int]: def __len__(self) -> int: """Return the length of the calibration.""" - return len(self.calibration) @classmethod @@ -52,9 +51,7 @@ def load_calibration( num_items: Optional[int] = None, calibration_mode: Literal["duration", "revolutions"] = "duration", ) -> PumpCalibration: - """Load a calibration from a file, dictionary, list, or value. :param calibration: pump - calibration file, dictionary, list, or value. If None, returns an empty PumpCalibration - object. + """Load a calibration from a file, dictionary, list, or value. Args: calibration: pump calibration file, dictionary, list, or value. @@ -101,29 +98,13 @@ def serialize(self) -> dict: "calibration_mode": self.calibration_mode, } - @classmethod - def deserialize(cls, data: dict) -> PumpCalibration: - return cls( - calibration=data["calibration"], - calibration_mode=data["calibration_mode"], - ) - @classmethod def load_from_json( cls, file_path: str, calibration_mode: Literal["duration", "revolutions"] = "duration", ) -> PumpCalibration: - """Load a calibration from a json file. - - Args: - file_path: json file to load calibration from. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - - Raises: - TypeError: if the calibration pulled from the json is not a dictionary or list. - """ + """Load a calibration from a json file.""" with open(file_path, "rb") as f: calibration = json.load(f) @@ -142,14 +123,7 @@ def load_from_csv( file_path: str, calibration_mode: Literal["duration", "revolutions"] = "duration", ) -> PumpCalibration: - """Load a calibration from a csv file. - - Args: - file_path: csv file to load calibration from. 0-indexed. The first column is treated as the - index, the second column as the value. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - """ + """Load a calibration from a csv file.""" with open(file_path, encoding="utf-8", newline="") as f: csv_file = list(csv.reader(f)) @@ -167,16 +141,7 @@ def load_from_dict( calibration: Dict[int, Union[int, float]], calibration_mode: Literal["duration", "revolutions"] = "duration", ) -> PumpCalibration: - """Load a calibration from a dictionary. - - Args: - calibration: dictionary to load calibration from. 0-indexed. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - - Raises: - ValueError: if the calibration dictionary is not formatted correctly. - """ + """Load a calibration from a dictionary (0-indexed).""" if sorted(calibration.keys()) != list(range(len(calibration))): raise ValueError("Keys must be a contiguous range of integers starting at 0.") @@ -189,14 +154,7 @@ def load_from_list( calibration: List[Union[int, float]], calibration_mode: Literal["duration", "revolutions"] = "duration", ) -> PumpCalibration: - """Load a calibration from a list. Equivalent to PumpCalibration(calibration). - - Args: - calibration: list to load calibration from. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - """ - + """Load a calibration from a list.""" return cls(calibration=calibration, calibration_mode=calibration_mode) @classmethod @@ -206,14 +164,6 @@ def load_from_value( num_items: int, calibration_mode: Literal["duration", "revolutions"] = "duration", ) -> PumpCalibration: - """Load a calibration from a value. Equivalent to PumpCalibration([value] * num_items). - - Args: - value: value to load calibration from. - num_items: number of items in the calibration. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - """ - + """Load a calibration from a single value applied to all channels.""" calibration = [value] * num_items return cls(calibration, calibration_mode) diff --git a/pylabrobot/capabilities/pumping/chatterbox.py b/pylabrobot/capabilities/pumping/chatterbox.py new file mode 100644 index 00000000000..efdf806ff70 --- /dev/null +++ b/pylabrobot/capabilities/pumping/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from .backend import PumpBackend + +logger = logging.getLogger(__name__) + + +class PumpChatterboxBackend(PumpBackend): + """Chatterbox backend for device-free testing.""" + + async def run_revolutions(self, num_revolutions: float): + logger.info("Running %s revolutions.", num_revolutions) + + async def run_continuously(self, speed: float): + logger.info("Running continuously at speed %s.", speed) + + async def halt(self): + logger.info("Halting the pump.") diff --git a/pylabrobot/pumps/errors.py b/pylabrobot/capabilities/pumping/errors.py similarity index 100% rename from pylabrobot/pumps/errors.py rename to pylabrobot/capabilities/pumping/errors.py diff --git a/pylabrobot/pumps/pump.py b/pylabrobot/capabilities/pumping/pumping.py similarity index 59% rename from pylabrobot/pumps/pump.py rename to pylabrobot/capabilities/pumping/pumping.py index 9d92f7257ba..0740737c847 100644 --- a/pylabrobot/pumps/pump.py +++ b/pylabrobot/capabilities/pumping/pumping.py @@ -1,14 +1,18 @@ import asyncio from typing import Optional, Union -from pylabrobot.machines.machine import Machine +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.pumping.errors import NotCalibratedError from .backend import PumpBackend from .calibration import PumpCalibration -class Pump(Machine): - """Frontend for a (peristaltic) pump.""" +class Pump(Capability): + """Single-pump capability. + + See :doc:`/user_guide/capabilities/pumping` for a walkthrough. + """ def __init__( self, @@ -16,75 +20,59 @@ def __init__( calibration: Optional[PumpCalibration] = None, ): super().__init__(backend=backend) - self.backend: PumpBackend = backend # fix type + self.backend: PumpBackend = backend if calibration is not None and len(calibration) != 1: raise ValueError("Calibration may only have a single item for this pump") self.calibration = calibration def serialize(self) -> dict: - if self.calibration is None: - return super().serialize() - return { - **super().serialize(), - "calibration": self.calibration.serialize(), - } - - @classmethod - def deserialize(cls, data: dict): - data_copy = data.copy() - calibration_data = data_copy.pop("calibration", None) - if calibration_data is not None: - calibration = PumpCalibration.deserialize(calibration_data) - data_copy["calibration"] = calibration - return super().deserialize(data_copy) + base: dict = {"type": self.__class__.__name__} + if self.calibration is not None: + base["calibration"] = self.calibration.serialize() + return base + @need_capability_ready async def run_revolutions(self, num_revolutions: float): - """Run a given number of revolutions. This method will return after the command has been sent, - and the pump will run until `halt` is called. + """Run for a given number of revolutions. Args: - num_revolutions: number of revolutions to run + num_revolutions: number of revolutions to run. """ + await self.backend.run_revolutions(num_revolutions=num_revolutions) - self.backend.run_revolutions(num_revolutions=num_revolutions) - + @need_capability_ready async def run_continuously(self, speed: float): - """Run continuously at a given speed. This method will return after the command has been sent, - and the pump will run until `halt` is called. - - If speed is 0, the pump will be halted. + """Run continuously at a given speed. If speed is 0, the pump will be halted. Args: speed: speed in rpm/pump-specific units. """ + await self.backend.run_continuously(speed=speed) - self.backend.run_continuously(speed=speed) - + @need_capability_ready async def run_for_duration(self, speed: Union[float, int], duration: Union[float, int]): """Run the pump at specified speed for the specified duration. Args: speed: speed in rpm/pump-specific units. - duration: duration to run pump. + duration: duration in seconds. """ - if duration < 0: raise ValueError("Duration must be positive.") await self.run_continuously(speed=speed) await asyncio.sleep(duration) await self.run_continuously(speed=0) + @need_capability_ready async def pump_volume(self, speed: Union[float, int], volume: Union[float, int]): - """Run the pump at specified speed for the specified volume. Note that this function requires - the pump to be calibrated at the input speed. + """Run the pump at specified speed for the specified volume. Requires calibration. Args: speed: speed in rpm/pump-specific units. volume: volume to pump. """ - if self.calibration is None: - raise TypeError( + raise NotCalibratedError( "Pump is not calibrated. Volume based pumping and related functions unavailable." ) if self.calibration.calibration_mode == "duration": @@ -96,6 +84,12 @@ async def pump_volume(self, speed: Union[float, int], volume: Union[float, int]) else: raise ValueError("Calibration mode not recognized.") + @need_capability_ready async def halt(self): """Halt the pump.""" - self.backend.halt() + await self.backend.halt() + + async def _on_stop(self): + if self._setup_finished: + await self.backend.halt() + await super()._on_stop() diff --git a/pylabrobot/capabilities/pumping/pumping_tests.py b/pylabrobot/capabilities/pumping/pumping_tests.py new file mode 100644 index 00000000000..e862eb9a072 --- /dev/null +++ b/pylabrobot/capabilities/pumping/pumping_tests.py @@ -0,0 +1,79 @@ +import unittest +from unittest.mock import AsyncMock, Mock + +from pylabrobot.capabilities.pumping.backend import PumpBackend +from pylabrobot.capabilities.pumping.calibration import PumpCalibration +from pylabrobot.capabilities.pumping.errors import NotCalibratedError +from pylabrobot.capabilities.pumping.pumping import Pump + + +class TestPump(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_backend = Mock(spec=PumpBackend) + self.mock_backend.run_revolutions = AsyncMock() + self.mock_backend.run_continuously = AsyncMock() + self.mock_backend.halt = AsyncMock() + self.test_calibration = PumpCalibration.load_calibration(1, num_items=1) + + async def _make_cap(self, calibration=None): + cap = Pump(backend=self.mock_backend, calibration=calibration) + await cap._on_setup() + return cap + + async def test_setup(self): + cap = await self._make_cap() + self.assertIsNone(cap.calibration) + self.assertTrue(cap.setup_finished) + + async def test_run_revolutions(self): + cap = await self._make_cap() + await cap.run_revolutions(num_revolutions=1) + self.mock_backend.run_revolutions.assert_called_once_with(num_revolutions=1) + + async def test_run_continuously(self): + cap = await self._make_cap() + await cap.run_continuously(speed=100) + self.mock_backend.run_continuously.assert_called_once_with(speed=100) + + async def test_halt(self): + cap = await self._make_cap() + await cap.halt() + self.mock_backend.halt.assert_called_once() + + async def test_run_for_duration(self): + cap = await self._make_cap() + await cap.run_for_duration(speed=1, duration=0) + self.mock_backend.run_continuously.assert_called_with(speed=0) + + async def test_run_invalid_duration(self): + cap = await self._make_cap() + with self.assertRaises(ValueError): + await cap.run_for_duration(speed=1, duration=-1) + + async def test_pump_volume_duration_mode(self): + cap = await self._make_cap(calibration=self.test_calibration) + cap.calibration.calibration_mode = "duration" + cap.run_for_duration = AsyncMock() + await cap.pump_volume(speed=1, volume=1) + cap.run_for_duration.assert_called_once_with(speed=1, duration=1.0) + + async def test_pump_volume_revolutions_mode(self): + cap = await self._make_cap(calibration=self.test_calibration) + cap.calibration.calibration_mode = "revolutions" + cap.run_revolutions = AsyncMock() + await cap.pump_volume(speed=1, volume=1) + cap.run_revolutions.assert_called_once_with(num_revolutions=1.0) + + async def test_pump_volume_no_calibration(self): + cap = await self._make_cap() + with self.assertRaises(NotCalibratedError): + await cap.pump_volume(speed=1, volume=1) + + async def test_not_setup_raises(self): + cap = Pump(backend=self.mock_backend) + with self.assertRaises(RuntimeError): + await cap.run_continuously(speed=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/sealing/__init__.py b/pylabrobot/capabilities/sealing/__init__.py new file mode 100644 index 00000000000..ff78148e8cc --- /dev/null +++ b/pylabrobot/capabilities/sealing/__init__.py @@ -0,0 +1,2 @@ +from .backend import SealerBackend +from .sealing import Sealer diff --git a/pylabrobot/capabilities/sealing/backend.py b/pylabrobot/capabilities/sealing/backend.py new file mode 100644 index 00000000000..9f2daa91fad --- /dev/null +++ b/pylabrobot/capabilities/sealing/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class SealerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for sealing devices.""" + + @abstractmethod + async def seal(self, temperature: int, duration: float): + """Perform a seal operation at the given temperature and duration.""" + + @abstractmethod + async def open(self): + """Open the sealer shuttle.""" + + @abstractmethod + async def close(self): + """Close the sealer shuttle.""" diff --git a/pylabrobot/capabilities/sealing/chatterbox.py b/pylabrobot/capabilities/sealing/chatterbox.py new file mode 100644 index 00000000000..7a94ee580de --- /dev/null +++ b/pylabrobot/capabilities/sealing/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from .backend import SealerBackend + +logger = logging.getLogger(__name__) + + +class SealerChatterboxBackend(SealerBackend): + """Chatterbox backend for device-free testing.""" + + async def seal(self, temperature: int, duration: float): + logger.info("Sealing at %s C for %s seconds.", temperature, duration) + + async def open(self): + logger.info("Opening sealer shuttle.") + + async def close(self): + logger.info("Closing sealer shuttle.") diff --git a/pylabrobot/capabilities/sealing/sealing.py b/pylabrobot/capabilities/sealing/sealing.py new file mode 100644 index 00000000000..feabb404c95 --- /dev/null +++ b/pylabrobot/capabilities/sealing/sealing.py @@ -0,0 +1,29 @@ +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import SealerBackend + + +class Sealer(Capability): + """Sealing capability. + + See :doc:`/user_guide/capabilities/sealing` for a walkthrough. + """ + + def __init__(self, backend: SealerBackend): + super().__init__(backend=backend) + self.backend: SealerBackend = backend + + @need_capability_ready + async def seal(self, temperature: int, duration: float): + await self.backend.seal(temperature=temperature, duration=duration) + + @need_capability_ready + async def open(self): + await self.backend.open() + + @need_capability_ready + async def close(self): + await self.backend.close() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/shaking/__init__.py b/pylabrobot/capabilities/shaking/__init__.py new file mode 100644 index 00000000000..9567c969858 --- /dev/null +++ b/pylabrobot/capabilities/shaking/__init__.py @@ -0,0 +1,2 @@ +from .backend import HasContinuousShaking, ShakerBackend +from .shaking import Shaker diff --git a/pylabrobot/capabilities/shaking/backend.py b/pylabrobot/capabilities/shaking/backend.py new file mode 100644 index 00000000000..586fe49e24c --- /dev/null +++ b/pylabrobot/capabilities/shaking/backend.py @@ -0,0 +1,53 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend + + +class ShakerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for shaking devices.""" + + @abstractmethod + async def shake( + self, + speed: float, + duration: float, + backend_params: Optional[BackendParams] = None, + ): + """Shake at the given speed for the given duration. + + Args: + speed: Speed in RPM. + duration: Duration in seconds. + backend_params: Backend-specific parameters. + """ + + @property + @abstractmethod + def supports_locking(self) -> bool: + """Whether this backend supports locking the plate.""" + + @abstractmethod + async def lock_plate(self): + """Lock the plate.""" + + @abstractmethod + async def unlock_plate(self): + """Unlock the plate.""" + + +class HasContinuousShaking(metaclass=ABCMeta): + """Mixin for shakers that support independent start/stop control. + + Similar to :class:`~pylabrobot.capabilities.arms.backend.HasJoints` for arms, + this mixin adds optional capability to backends that support continuous + (indefinite) shaking with explicit start and stop commands. + """ + + @abstractmethod + async def start_shaking(self, speed: float): + """Start shaking indefinitely at the given speed in RPM.""" + + @abstractmethod + async def stop_shaking(self): + """Stop shaking.""" diff --git a/pylabrobot/capabilities/shaking/chatterbox.py b/pylabrobot/capabilities/shaking/chatterbox.py new file mode 100644 index 00000000000..9fb632f1895 --- /dev/null +++ b/pylabrobot/capabilities/shaking/chatterbox.py @@ -0,0 +1,32 @@ +import asyncio +import logging + +from .backend import HasContinuousShaking, ShakerBackend + +logger = logging.getLogger(__name__) + + +class ShakerChatterboxBackend(ShakerBackend, HasContinuousShaking): + """Chatterbox backend for device-free testing.""" + + @property + def supports_locking(self) -> bool: + return True + + async def shake(self, speed: float, duration: float, backend_params=None): + logger.info("Shaking at %s RPM for %s seconds.", speed, duration) + await self.start_shaking(speed=speed) + await asyncio.sleep(duration) + await self.stop_shaking() + + async def start_shaking(self, speed: float): + logger.info("Starting shaking at %s RPM.", speed) + + async def stop_shaking(self): + logger.info("Stopping shaking.") + + async def lock_plate(self): + logger.info("Locking plate.") + + async def unlock_plate(self): + logger.info("Unlocking plate.") diff --git a/pylabrobot/capabilities/shaking/shaking.py b/pylabrobot/capabilities/shaking/shaking.py new file mode 100644 index 00000000000..ceaaf7d962b --- /dev/null +++ b/pylabrobot/capabilities/shaking/shaking.py @@ -0,0 +1,85 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready + +from .backend import HasContinuousShaking, ShakerBackend + + +class Shaker(Capability): + """Shaking capability. + + See :doc:`/user_guide/capabilities/shaking` for a walkthrough. + """ + + def __init__(self, backend: ShakerBackend): + super().__init__(backend=backend) + self.backend: ShakerBackend = backend + + @need_capability_ready + async def shake( + self, + speed: float, + duration: float, + backend_params: Optional[BackendParams] = None, + ): + """Shake at the given speed for the given duration. + + Args: + speed: Speed in RPM. + duration: Duration in seconds. + backend_params: Backend-specific parameters. + """ + if self.backend.supports_locking: + await self.backend.lock_plate() + try: + await self.backend.shake(speed=speed, duration=duration, backend_params=backend_params) + finally: + if self.backend.supports_locking: + await self.backend.unlock_plate() + + @need_capability_ready + async def start_shaking(self, speed: float): + """Start shaking indefinitely. + + Only available if the backend supports continuous shaking + (implements :class:`~pylabrobot.capabilities.shaking.backend.HasContinuousShaking`). + + Args: + speed: Speed in RPM. + """ + if not isinstance(self.backend, HasContinuousShaking): + raise NotImplementedError( + f"{type(self.backend).__name__} does not support continuous shaking. " + "Use shake(speed, duration) instead." + ) + if self.backend.supports_locking: + await self.backend.lock_plate() + await self.backend.start_shaking(speed=speed) + + @need_capability_ready + async def stop_shaking(self): + """Stop shaking. + + Only available if the backend supports continuous shaking + (implements :class:`~pylabrobot.capabilities.shaking.backend.HasContinuousShaking`). + """ + if not isinstance(self.backend, HasContinuousShaking): + raise NotImplementedError( + f"{type(self.backend).__name__} does not support continuous shaking." + ) + await self.backend.stop_shaking() + if self.backend.supports_locking: + await self.backend.unlock_plate() + + @need_capability_ready + async def lock_plate(self): + await self.backend.lock_plate() + + @need_capability_ready + async def unlock_plate(self): + await self.backend.unlock_plate() + + async def _on_stop(self): + if self._setup_finished and isinstance(self.backend, HasContinuousShaking): + await self.backend.stop_shaking() + await super()._on_stop() diff --git a/pylabrobot/capabilities/temperature_controlling/__init__.py b/pylabrobot/capabilities/temperature_controlling/__init__.py new file mode 100644 index 00000000000..ad6071ef2d5 --- /dev/null +++ b/pylabrobot/capabilities/temperature_controlling/__init__.py @@ -0,0 +1,2 @@ +from .backend import TemperatureControllerBackend +from .temperature_controller import TemperatureController diff --git a/pylabrobot/capabilities/temperature_controlling/backend.py b/pylabrobot/capabilities/temperature_controlling/backend.py new file mode 100644 index 00000000000..d204b656462 --- /dev/null +++ b/pylabrobot/capabilities/temperature_controlling/backend.py @@ -0,0 +1,24 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class TemperatureControllerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for temperature controllers.""" + + @property + @abstractmethod + def supports_active_cooling(self) -> bool: + """Whether this backend can actively cool below the current temperature.""" + + @abstractmethod + async def set_temperature(self, temperature: float): + """Set the temperature of the temperature controller in Celsius.""" + + @abstractmethod + async def request_current_temperature(self) -> float: + """Get the current temperature of the temperature controller in Celsius""" + + @abstractmethod + async def deactivate(self): + """Deactivate the temperature controller.""" diff --git a/pylabrobot/capabilities/temperature_controlling/chatterbox.py b/pylabrobot/capabilities/temperature_controlling/chatterbox.py new file mode 100644 index 00000000000..25f9529947d --- /dev/null +++ b/pylabrobot/capabilities/temperature_controlling/chatterbox.py @@ -0,0 +1,27 @@ +import logging + +from .backend import TemperatureControllerBackend + +logger = logging.getLogger(__name__) + + +class TemperatureControllerChatterboxBackend(TemperatureControllerBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._temperature = 22.0 + + @property + def supports_active_cooling(self) -> bool: + return True + + async def set_temperature(self, temperature: float): + logger.info("Setting temperature to %s C.", temperature) + self._temperature = temperature + + async def request_current_temperature(self) -> float: + return self._temperature + + async def deactivate(self): + logger.info("Deactivating temperature controller.") + self._temperature = 22.0 diff --git a/pylabrobot/temperature_controlling/temperature_controller.py b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py similarity index 66% rename from pylabrobot/temperature_controlling/temperature_controller.py rename to pylabrobot/capabilities/temperature_controlling/temperature_controller.py index 3b58305c7dc..521c1cffd21 100644 --- a/pylabrobot/temperature_controlling/temperature_controller.py +++ b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py @@ -2,40 +2,23 @@ import time from typing import Optional -from pylabrobot.machines.machine import Machine -from pylabrobot.resources import Coordinate, ResourceHolder +from pylabrobot.capabilities.capability import Capability, need_capability_ready from .backend import TemperatureControllerBackend -class TemperatureController(ResourceHolder, Machine): - """Temperature controller, for heating or for cooling.""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: TemperatureControllerBackend, - 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, - ) - Machine.__init__(self, backend=backend) +class TemperatureController(Capability): + """Temperature control capability, for heating or cooling. + + See :doc:`/user_guide/capabilities/temperature-control` for a walkthrough. + """ + + def __init__(self, backend: TemperatureControllerBackend): + super().__init__(backend=backend) self.backend: TemperatureControllerBackend = backend # fix type self.target_temperature: Optional[float] = None + @need_capability_ready async def set_temperature(self, temperature: float, passive: bool = False): """Set the temperature of the temperature controller. @@ -46,7 +29,7 @@ async def set_temperature(self, temperature: float, passive: bool = False): This can be used for backends that do not support active cooling or to explicitly disable active cooling when it is available. """ - current = await self.backend.get_current_temperature() + current = await self.backend.request_current_temperature() self.target_temperature = temperature @@ -64,10 +47,12 @@ async def set_temperature(self, temperature: float, passive: bool = False): return await self.backend.set_temperature(temperature) - async def get_temperature(self) -> float: + @need_capability_ready + async def request_temperature(self) -> float: """Get the current temperature of the temperature controller in Celsius.""" - return await self.backend.get_current_temperature() + return await self.backend.request_current_temperature() + @need_capability_ready async def wait_for_temperature(self, timeout: float = 300.0, tolerance: float = 0.5) -> None: """Wait for the temperature to reach the target temperature. The target temperature must be set by `set_temperature()`. @@ -80,12 +65,13 @@ async def wait_for_temperature(self, timeout: float = 300.0, tolerance: float = raise RuntimeError("Target temperature is not set.") start = time.time() while time.time() - start < timeout: - temperature = await self.get_temperature() + temperature = await self.request_temperature() if abs(temperature - self.target_temperature) < tolerance: return await asyncio.sleep(1.0) raise TimeoutError(f"Temperature did not reach target temperature within {timeout} seconds.") + @need_capability_ready async def deactivate(self): """Deactivate the temperature controller. This will stop the heating or cooling, and return the temperature to ambient temperature. The target temperature will be reset to `None`. @@ -93,13 +79,8 @@ async def deactivate(self): self.target_temperature = None return await self.backend.deactivate() - async def stop(self): - """Stop the temperature controller and close the backend connection.""" - await self.deactivate() - await super().stop() - - def serialize(self) -> dict: - return { - **Machine.serialize(self), - **ResourceHolder.serialize(self), - } + async def _on_stop(self): + """Called by the parent Machine before backend.stop().""" + if self._setup_finished: + await self.deactivate() + await super()._on_stop() diff --git a/pylabrobot/capabilities/tilting/__init__.py b/pylabrobot/capabilities/tilting/__init__.py new file mode 100644 index 00000000000..e01d031b758 --- /dev/null +++ b/pylabrobot/capabilities/tilting/__init__.py @@ -0,0 +1,2 @@ +from .backend import TilterBackend, TiltModuleError +from .tilting import Tilter diff --git a/pylabrobot/capabilities/tilting/backend.py b/pylabrobot/capabilities/tilting/backend.py new file mode 100644 index 00000000000..dfc22641baa --- /dev/null +++ b/pylabrobot/capabilities/tilting/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class TiltModuleError(Exception): + """Error raised by a tilt module backend.""" + + +class TilterBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for tilting devices.""" + + @abstractmethod + async def set_angle(self, angle: float): + """Set the tilt angle. + + Args: + angle: The angle in degrees. 0 is horizontal. + """ diff --git a/pylabrobot/capabilities/tilting/chatterbox.py b/pylabrobot/capabilities/tilting/chatterbox.py new file mode 100644 index 00000000000..ef94217ca89 --- /dev/null +++ b/pylabrobot/capabilities/tilting/chatterbox.py @@ -0,0 +1,12 @@ +import logging + +from .backend import TilterBackend + +logger = logging.getLogger(__name__) + + +class TilterChatterboxBackend(TilterBackend): + """Chatterbox backend for device-free testing.""" + + async def set_angle(self, angle: float): + logger.info("Setting tilt angle to %s degrees.", angle) diff --git a/pylabrobot/capabilities/tilting/tilting.py b/pylabrobot/capabilities/tilting/tilting.py new file mode 100644 index 00000000000..11c31de63be --- /dev/null +++ b/pylabrobot/capabilities/tilting/tilting.py @@ -0,0 +1,41 @@ +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import TilterBackend + + +class Tilter(Capability): + """Tilting capability. + + See :doc:`/user_guide/capabilities/tilting` for a walkthrough. + """ + + def __init__(self, backend: TilterBackend): + super().__init__(backend=backend) + self.backend: TilterBackend = backend + self._absolute_angle: float = 0 + + @property + def absolute_angle(self) -> float: + return self._absolute_angle + + @need_capability_ready + async def set_angle(self, absolute_angle: float): + """Set the tilt angle. + + Args: + absolute_angle: The absolute angle in degrees. 0 is horizontal. + """ + await self.backend.set_angle(angle=absolute_angle) + self._absolute_angle = absolute_angle + + @need_capability_ready + async def tilt(self, relative_angle: float): + """Tilt by a relative angle from the current position. + + Args: + relative_angle: The angle to tilt by, in degrees. + """ + await self.set_angle(self._absolute_angle + relative_angle) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/weighing/__init__.py b/pylabrobot/capabilities/weighing/__init__.py new file mode 100644 index 00000000000..3dfa7d99758 --- /dev/null +++ b/pylabrobot/capabilities/weighing/__init__.py @@ -0,0 +1,2 @@ +from .backend import ScaleBackend +from .weighing import Scale diff --git a/pylabrobot/capabilities/weighing/backend.py b/pylabrobot/capabilities/weighing/backend.py new file mode 100644 index 00000000000..f9418a284bf --- /dev/null +++ b/pylabrobot/capabilities/weighing/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class ScaleBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for scales.""" + + @abstractmethod + async def zero(self): + """Zero the scale.""" + + @abstractmethod + async def tare(self): + """Tare the scale.""" + + @abstractmethod + async def read_weight(self) -> float: + """Read the weight in grams.""" diff --git a/pylabrobot/capabilities/weighing/chatterbox.py b/pylabrobot/capabilities/weighing/chatterbox.py new file mode 100644 index 00000000000..440a1011bf6 --- /dev/null +++ b/pylabrobot/capabilities/weighing/chatterbox.py @@ -0,0 +1,23 @@ +import logging + +from .backend import ScaleBackend + +logger = logging.getLogger(__name__) + + +class ScaleChatterboxBackend(ScaleBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._weight = 0.0 + + async def zero(self): + logger.info("Zeroing scale.") + self._weight = 0.0 + + async def tare(self): + logger.info("Taring scale.") + self._weight = 0.0 + + async def read_weight(self) -> float: + return self._weight diff --git a/pylabrobot/capabilities/weighing/weighing.py b/pylabrobot/capabilities/weighing/weighing.py new file mode 100644 index 00000000000..c3d3c04b23f --- /dev/null +++ b/pylabrobot/capabilities/weighing/weighing.py @@ -0,0 +1,29 @@ +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import ScaleBackend + + +class Scale(Capability): + """Weighing capability. + + See :doc:`/user_guide/capabilities/weighing` for a walkthrough. + """ + + def __init__(self, backend: ScaleBackend): + super().__init__(backend=backend) + self.backend: ScaleBackend = backend + + @need_capability_ready + async def zero(self): + await self.backend.zero() + + @need_capability_ready + async def tare(self): + await self.backend.tare() + + @need_capability_ready + async def read_weight(self) -> float: + return await self.backend.read_weight() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index df6a67cc292..0d09060a8eb 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -1,10 +1,9 @@ -from .access2 import Access2 -from .centrifuge import Centrifuge, Loader -from .standard import ( - BucketHasPlateError, - BucketNoPlateError, - CentrifugeDoorError, - LoaderNoPlateError, - NotAtBucketError, +import warnings + +warnings.warn( + "Importing from pylabrobot.centrifuge is deprecated. Use pylabrobot.legacy.centrifuge instead.", + DeprecationWarning, + stacklevel=2, ) -from .vspin_backend import Access2Backend, VSpinBackend + +from pylabrobot.legacy.centrifuge import * # noqa: F401,F403,E402 diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py deleted file mode 100644 index 021a550f359..00000000000 --- a/pylabrobot/centrifuge/vspin_backend.py +++ /dev/null @@ -1,649 +0,0 @@ -import asyncio -import ctypes -import json -import logging -import math -import os -import time -import warnings -from typing import Optional - -from pylabrobot.io.ftdi import FTDI - -from .backend import CentrifugeBackend, LoaderBackend -from .standard import LoaderNoPlateError - -logger = logging.getLogger(__name__) - - -class Access2Backend(LoaderBackend): - def __init__( - self, - device_id: str, - timeout: int = 60, - ): - """ - Args: - device_id: The libftdi id for the loader. Find using - `python3 -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) - self.timeout = timeout - - async def _read(self) -> bytes: - x = b"" - r = None - start = time.time() - while r != b"" or x == b"": - r = await self.io.read(1) - x += r - if r == b"": - await asyncio.sleep(0.1) - if x == b"" and (time.time() - start) > self.timeout: - raise TimeoutError("No data received within the specified timeout period") - return x - - async def send_command(self, command: bytes) -> bytes: - logger.debug("[loader] Sending %s", command.hex()) - await self.io.write(command) - return await self._read() - - async def setup(self): - logger.debug("[loader] setup") - - await self.io.setup() - await self.io.set_baudrate(115384) - - status = await self.get_status() - if not status.startswith(bytes.fromhex("1105")): - raise RuntimeError("Failed to get status") - - await self.send_command(bytes.fromhex("110500030014000072b1")) - await self.send_command(bytes.fromhex("1105000300100000ae71")) - await self.send_command(bytes.fromhex("110500070024040000008000be89")) - await self.send_command(bytes.fromhex("11050007002404008000800063b1")) - await self.send_command(bytes.fromhex("11050007002404000001800089b9")) - await self.send_command(bytes.fromhex("1105000700240400800180005481")) - await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) - await self.send_command(bytes.fromhex("1105000300400000f0bf")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - async def stop(self): - logger.debug("[loader] stop") - await self.io.stop() - - def serialize(self): - return {"io": self.io.serialize(), "timeout": self.timeout} - - async def get_status(self) -> bytes: - logger.debug("[loader] get_status") - return await self.send_command(bytes.fromhex("11050003002000006bd4")) - - async def park(self): - logger.debug("[loader] park") - await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) - - async def close(self): - logger.debug("[loader] close") - await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) - - async def open(self): - logger.debug("[loader] open") - await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) - - async def load(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] load") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found on stage") - - await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) - - async def unload(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] unload") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found in centrifuge") - - await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) - await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - -_vspin_bucket_calibrations_path = os.path.join( - os.path.expanduser("~"), - ".pylabrobot", - "vspin_bucket_calibrations.json", -) - - -def _load_vspin_calibrations(device_id: str) -> Optional[int]: - if not os.path.exists(_vspin_bucket_calibrations_path): - warnings.warn( - f"No calibration found for VSpin with device id {device_id}. " - "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", - UserWarning, - ) - return None - with open(_vspin_bucket_calibrations_path, "r") as f: - return json.load(f).get(device_id) # type: ignore - - -def _save_vspin_calibrations(device_id, remainder: int): - if os.path.exists(_vspin_bucket_calibrations_path): - with open(_vspin_bucket_calibrations_path, "r") as f: - data = json.load(f) - else: - data = {} - data[device_id] = remainder - os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) - with open(_vspin_bucket_calibrations_path, "w") as f: - json.dump(data, f) - - -FULL_ROTATION: int = 8000 - - -bucket_1_not_set_error = RuntimeError( - "Bucket 1 position not set. " - "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " - "then calling VSpinBackend.set_bucket_1_position_to_current." -) - - -class VSpinBackend(CentrifugeBackend): - """Backend for the Agilent Centrifuge. - Note that this is not a complete implementation.""" - - def __init__(self, device_id: Optional[str] = None): - """ - Args: - device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = None - # only attempt loading calibration if device_id is not None - # if it is None, we will load it after setup when we can query the device id from the io - if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - - async def setup(self): - await self.io.setup() - # TODO: add functionality where if robot has been initialized before nothing needs to happen - for _ in range(3): - await self.configure_and_initialize() - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa01132034")) - await self._send_command(bytes.fromhex("aa002102ff22")) - await self._send_command(bytes.fromhex("aa02132035")) - await self._send_command(bytes.fromhex("aa002103ff23")) - await self._send_command(bytes.fromhex("aaff1a142d")) - - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - await self._send_command(bytes.fromhex("aa01121f32")) - for _ in range(8): - await self._send_command(bytes.fromhex("aa0220ff0f30")) - await self._send_command(bytes.fromhex("aa0220df0f10")) - await self._send_command(bytes.fromhex("aa0220df0e0f")) - await self._send_command(bytes.fromhex("aa0220df0c0d")) - await self._send_command(bytes.fromhex("aa0220df0809")) - for _ in range(4): - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa02120317")) - for _ in range(5): - await self._send_command(bytes.fromhex("aa0226200048")) - await self._send_command(bytes.fromhex("aa0226000028")) - await self.lock_door() - - await self._send_command(bytes.fromhex("aa0226000028")) - - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) - - resp = 0x89 - while resp == 0x89: - resp = (await self._get_positions_and_tachometer()).status - - # --- almost the same as go to position --- - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") # arbitrary - # rpm = 600, - # acceleration = 75.09289617486338 - await self._send_command( - bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") - ) - # ----------------------------------------- - - resp = 0x08 - while resp != 0x09: - resp = (await self._get_positions_and_tachometer()).status - - await self._send_command(bytes.fromhex("aa0117021a")) - - await self.lock_door() - - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - - @property - def bucket_1_remainder(self) -> int: - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - return self._bucket_1_remainder - - async def set_bucket_1_position_to_current(self) -> None: - """Set the current position as bucket 1 position and save calibration.""" - current_position = await self.get_position() - device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position - self._bucket_1_remainder = current_position % FULL_ROTATION - _save_vspin_calibrations(device_id, remainder) - - async def get_bucket_1_position(self) -> int: - """Get the bucket 1 position based on calibration. - Normally it is the home position minus the remainder (calibration). - The bucket 1 position must be greater than the current position, so we find - the first position greater than the current position by adding full rotations if needed. - """ - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - home_position = await self.get_home_position() - bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - # first number after current position that matches bucket 1 position mod FULL_ROTATION - current_position = await self.get_position() - bucket_1_position = ( - FULL_ROTATION - * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) - + bucket_1_position_mod_full_rotation - ) - return bucket_1_position - - async def stop(self): - await self.configure_and_initialize() - await self.io.stop() - - class _StatusPositionTachometer(ctypes.LittleEndianStructure): - _pack_ = 1 - _fields_ = [ - ("status", ctypes.c_uint8), - ("current_position", ctypes.c_uint32), - ("unknown1", ctypes.c_uint8), - ("tachometer", ctypes.c_int16), - ("unknown2", ctypes.c_uint8), - ("home_position", ctypes.c_uint32), - ("checksum", ctypes.c_uint8), - ] - - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - """Returns 14 bytes - - Example: - 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 - ^^ checksum - ^^ ^^ ^^ ^^ home position - ^^ ? (probably binary status objects) - ^^ ^^ tachometer - ^^ ? (probably binary status objects) - ^^ ^^ ^^ ^^ current position - ^^ - - First byte (index 0): - - 11 = 0b0001011 = idle - - 13 = 0b0001101 = unknown - - 08 = 0b0001000 = spinning - - 09 = 0b0001001 = also spinning but different - - 19 = 0b0010011 = unknown - - 88 = 0b1011000 = unknown - - 89 = 0b1011001 = unknown - - 10th to 13th byte (index 9-12) = Homing Position - - Last byte (index 13) = checksum - """ - resp = await self._send_command(bytes.fromhex("aa010e0f")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge") - return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) - - async def get_position(self) -> int: - return (await self._get_positions_and_tachometer()).current_position # type: ignore - - async def get_tachometer(self) -> int: - """current speed in rpm""" - tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM - return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore - - async def get_home_position(self) -> int: - """changes during a run, but the bucket 1 position relative to it does not""" - return (await self._get_positions_and_tachometer()).home_position # type: ignore - - async def _get_status(self): - """ - examples: - - 0080d0015 - - 0080f0015 - """ - - resp = await self._send_command(bytes.fromhex("aa020e10")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge. Is the machine on?") - return resp - - async def get_bucket_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0001 != 0 # type: ignore - - async def get_door_open(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0010 != 0 # type: ignore - - async def get_door_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0100 == 0 # type: ignore - - # Centrifuge communication: read_resp, send - - async def _read_resp(self, timeout: float = 20) -> bytes: - """Read a response from the centrifuge. If the timeout is reached, return the data that has - been read so far.""" - data = b"" - end_byte_found = False - start_time = time.time() - - while True: - chunk = await self.io.read(25) - if chunk: - data += chunk - end_byte_found = data[-1] == 0x0D - if len(chunk) < 25 and end_byte_found: - break - else: - if end_byte_found or time.time() - start_time > timeout: - break - await asyncio.sleep(0.0001) - - logger.debug("Read %s", data.hex()) - return data - - async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: - written = await self.io.write(bytes(cmd)) - - if written != len(cmd): - raise RuntimeError("Failed to write all bytes") - return await self._read_resp(timeout=read_timeout) - - async def configure_and_initialize(self): - await self.set_configuration_data() - await self.initialize() - - async def set_configuration_data(self): - """Set the device configuration data.""" - await self.io.set_latency_timer(16) - await self.io.set_line_property(bits=8, stopbits=1, parity=0) - await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) - - async def initialize(self): - await self.io.write(b"\x00" * 20) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await self._send_command(bytes.fromhex("aaff0f0e")) - - # Centrifuge operations - - async def open_door(self): - if await self.get_door_open(): - return - # used to be: aa022600072f - await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door - - # we can't tell when the door is fully open, so we just wait a bit - await asyncio.sleep(4) - - async def close_door(self): - if not (await self.get_door_open()): - return - # used to be: aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door - # we can't tell when the door is fully closed, so we just wait a bit - await asyncio.sleep(2) - - async def lock_door(self): - if await self.get_door_open(): - raise RuntimeError("Cannot lock door while it is open.") - if await self.get_door_locked(): - return - # used to be aa0226000129 - await self._send_command(bytes.fromhex("aa0226000028")) - - async def unlock_door(self): - if not await self.get_door_locked(): - return - # used to be aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as close door - - async def lock_bucket(self): - if await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600072f")) - - async def unlock_bucket(self): - if not await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600062e")) # same as open door - - async def go_to_bucket1(self): - await self.go_to_position(await self.get_bucket_1_position()) - - async def go_to_bucket2(self): - await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) - - async def go_to_position(self, position: int): - await self.close_door() - await self.lock_door() - - position_bytes = position.to_bytes(4, byteorder="little") - byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") - sum_byte = (sum(byte_string) - 0xAA) & 0xFF - byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(byte_string) - - # await self._send_command(bytes.fromhex("aa0117021a")) - while ( - abs(await self.get_position() - position) > 10 - ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) - await asyncio.sleep(0.1) - await self.open_door() - - @staticmethod - def g_to_rpm(g: float) -> int: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) - return rpm - - async def spin( - self, - g: float = 500, - duration: float = 60, - acceleration: float = 0.8, - deceleration: float = 0.8, - ) -> None: - """Start a spin cycle. spin spin spin spin - - Args: - g: relative centrifugal force, also known as g-force - duration: time in seconds spent at speed (g) - acceleration: 0-1 of total acceleration - deceleration: 0-1 of total deceleration - """ - - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if g < 1 or g > 1000: - raise ValueError("G-force must be within 1-1000") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") - - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if await self.get_bucket_locked(): - await self.unlock_bucket() - - # 1 - compute the final position - rpm = VSpinBackend.g_to_rpm(g) - - # compute the distance traveled during the acceleration period - # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max - # 12903.2 ticks/s^2 is 100% acceleration - acceleration_ticks_per_second2 = 12903.2 * acceleration - rounds_per_second = rpm / 60 - ticks_per_second = rounds_per_second * 8000 - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - - # compute the distance traveled at speed - distance_at_speed = ticks_per_second * duration - - current_position = await self.get_position() - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - # this is almost 3 hours of spinning at 3000 rpm (max speed), - # so we assume nobody will ever hit this. - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - # 2 - send "go to position" command with computed final position and rpm - position_b = final_position.to_bytes(4, byteorder="little") - rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") - acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") - - byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b - checksum = (sum(byte_string) - 0xAA) & 0xFF - byte_string += checksum.to_bytes(1, byteorder="little") - - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - - await self._send_command(byte_string) - - # 3 - wait for acceleration to the set rpm - # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) - while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: - await asyncio.sleep(0.1) - - # 4 - once the speed is reached, compute the position at which to start deceleration - # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. - # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. - # this is what the vendor software does too. - # if we are already past that position, we skip this part. - if await self.get_position() < final_position: - decel_start_position = await self.get_position() + distance_at_speed - - # then wait until we reach that position - while await self.get_position() < decel_start_position: - await asyncio.sleep(0.1) - - # 5 - send deceleration command - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - # aa0194b600000000dc02000029: decel at 80 - # aa0194b6000000000a03000058: decel at 85 - # aa0194b61283000012010000f3: used in setup (30%) - decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") - decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") - decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._send_command(decel_command) - - await asyncio.sleep(2) - - # 6 - reset position back to 0ish - # this part is aneeded because otherwise calling go_to_position will not work after - async def _reset_to_zero(): - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again - - await _reset_to_zero() - - # 7 - wait for home position to change - # go_to_bucket{1,2} does not work until the home position changes - start = await self.get_home_position() - num_tries = 0 - while await self.get_home_position() == start: - await asyncio.sleep(0.1) - num_tries += 1 - if num_tries % 25 == 0: - await _reset_to_zero() - if num_tries > 100: - raise RuntimeError("Home position did not change after spin.") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class VSpin: - def __init__(self, *args, **kwargs): - raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") diff --git a/pylabrobot/cole_parmer/__init__.py b/pylabrobot/cole_parmer/__init__.py new file mode 100644 index 00000000000..308c97ae35a --- /dev/null +++ b/pylabrobot/cole_parmer/__init__.py @@ -0,0 +1 @@ +from .masterflex_backend import MasterflexBackend, MasterflexDriver, MasterflexPump diff --git a/pylabrobot/cole_parmer/masterflex_backend.py b/pylabrobot/cole_parmer/masterflex_backend.py new file mode 100644 index 00000000000..2f1a4fcbe1f --- /dev/null +++ b/pylabrobot/cole_parmer/masterflex_backend.py @@ -0,0 +1,130 @@ +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.pumping.backend import PumpBackend +from pylabrobot.capabilities.pumping.calibration import PumpCalibration +from pylabrobot.capabilities.pumping.pumping import Pump +from pylabrobot.device import Device, Driver +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 MasterflexDriver(Driver): + """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, backend_params: Optional[BackendParams] = None): + 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): + command = "\x02P02" + command + "\x0d" + await self.io.write(command.encode()) + return await self.io.read() + + def serialize(self): + return {"type": self.__class__.__name__, "com_port": self.com_port} + + +class MasterflexBackend(PumpBackend): + """Pump capability backend for Masterflex L/S pumps.""" + + def __init__(self, driver: MasterflexDriver): + self.driver = driver + + async def run_revolutions(self, num_revolutions: float): + num_revolutions = round(num_revolutions, 2) + logger.info( + "[Masterflex %s] dispensing %.2f revolutions", self.driver.com_port, num_revolutions + ) + cmd = f"V{num_revolutions}G" + await self.driver.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.driver.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.driver.send_command(cmd) + + async def halt(self): + logger.info("[Masterflex %s] halting", self.driver.com_port) + await self.driver.send_command("H") + + def serialize(self): + return { + "com_port": self.driver.com_port, + } + + +class MasterflexPump(Device): + """Cole Parmer Masterflex L/S pump.""" + + def __init__( + self, + com_port: str, + calibration: Optional[PumpCalibration] = None, + ): + driver = MasterflexDriver(com_port=com_port) + super().__init__(driver=driver) + self.driver: MasterflexDriver + self.pumping = Pump(backend=MasterflexBackend(driver), calibration=calibration) + self._capabilities = [self.pumping] diff --git a/pylabrobot/device.py b/pylabrobot/device.py new file mode 100644 index 00000000000..bb58a9b040a --- /dev/null +++ b/pylabrobot/device.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import functools +import inspect +import sys +import weakref +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, TypeVar + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.object_parsing import find_subclass + +if TYPE_CHECKING: + from pylabrobot.capabilities.capability import Capability + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec +else: + from typing import ParamSpec + +_P = ParamSpec("_P") +_R = TypeVar("_R", bound=Awaitable[Any]) + + +class Driver(SerializableMixin, ABC): + """Abstract class for hardware drivers.""" + + _instances: weakref.WeakSet["Driver"] = weakref.WeakSet() + + def __init__(self): + self._instances.add(self) + + @abstractmethod + async def setup(self, backend_params: Optional[BackendParams] = None): + pass + + @abstractmethod + async def stop(self): + pass + + def serialize(self) -> dict: + return {"type": self.__class__.__name__} + + @classmethod + def deserialize(cls, data: dict): + class_name = data.pop("type") + subclass = find_subclass(class_name, cls=cls) + if subclass is None: + raise ValueError(f'Could not find subclass with name "{class_name}"') + if inspect.isabstract(subclass): + raise ValueError(f'Subclass with name "{class_name}" is abstract') + if not issubclass(subclass, cls): + raise RuntimeError(f'Subclass "{class_name}" is not a subclass of {cls.__name__}') + return subclass(**data) + + @classmethod + def get_all_instances(cls): + return cls._instances + + +def need_setup_finished(func: Callable[_P, _R]) -> Callable[_P, _R]: + """Decorator for methods that require the device to be set up. + + Checked by verifying `self.setup_finished` is `True`. + + Raises: + RuntimeError: If the device is not set up. + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + if not isinstance(args[0], Device): + raise RuntimeError("The first argument must be a Device.") + self = args[0] + + if not self.setup_finished: + raise RuntimeError("The setup has not finished. See `setup`.") + return await func(*args, **kwargs) + + return wrapper + + +class Device(SerializableMixin, ABC): + """Abstract base class for device frontends.""" + + def __init__(self, driver: Driver): + self.driver = driver + self._setup_finished = False + self._capabilities: List[Capability] = [] + + @property + def setup_finished(self) -> bool: + return self._setup_finished + + def serialize(self) -> dict: + return {"driver": self.driver.serialize()} + + @classmethod + def deserialize(cls, data: dict): + data_copy = data.copy() + driver_data = data_copy.pop("driver", None) or data_copy.pop("backend", None) + driver = Driver.deserialize(driver_data) + data_copy["driver"] = driver + return cls(**data_copy) + + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.driver.setup(backend_params=backend_params) + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + + async def stop(self): + if not self._setup_finished: + return + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + + async def __aenter__(self): + await self.setup() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.stop() diff --git a/pylabrobot/plate_reading/tecan/spark20m/__init__.py b/pylabrobot/hamilton/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/__init__.py rename to pylabrobot/hamilton/__init__.py diff --git a/pylabrobot/hamilton/heater_cooler/__init__.py b/pylabrobot/hamilton/heater_cooler/__init__.py new file mode 100644 index 00000000000..c0987ab1175 --- /dev/null +++ b/pylabrobot/hamilton/heater_cooler/__init__.py @@ -0,0 +1,5 @@ +from .backend import ( + HamiltonHeaterCoolerDriver, + HamiltonHeaterCoolerTemperatureBackend, +) +from .heater_cooler import HamiltonHeaterCooler diff --git a/pylabrobot/hamilton/heater_cooler/backend.py b/pylabrobot/hamilton/heater_cooler/backend.py new file mode 100644 index 00000000000..fbe90f7d4c5 --- /dev/null +++ b/pylabrobot/hamilton/heater_cooler/backend.py @@ -0,0 +1,47 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver + + +class HamiltonHeaterCoolerDriver(Driver): + """Serial driver for Hamilton Heater Cooler (HHC) via STAR TCC port. + + The HHC connects to the STAR liquid handler via RS-232 through TCC ports. + Communication uses the STAR firmware command protocol with module addressing. + + TODO: implement TCC serial communication. See legacy STAR_backend.py methods + (initialize_hhc, start_temperature_control_at_hhc, get_temperature_at_hhc, + stop_temperature_control_at_hhc) for the command protocol. + """ + + def __init__(self, device_number: int): + super().__init__() + self.device_number = device_number + + async def setup(self, backend_params: Optional[BackendParams] = None): + raise NotImplementedError("HamiltonHeaterCoolerDriver is not yet implemented.") + + async def stop(self): + raise NotImplementedError("HamiltonHeaterCoolerDriver is not yet implemented.") + + +class HamiltonHeaterCoolerTemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend calls into HHC serial commands.""" + + def __init__(self, driver: HamiltonHeaterCoolerDriver): + self.driver = driver + + @property + def supports_active_cooling(self) -> bool: + return True + + async def set_temperature(self, temperature: float): + raise NotImplementedError("HamiltonHeaterCoolerTemperatureBackend is not yet implemented.") + + async def request_current_temperature(self) -> float: + raise NotImplementedError("HamiltonHeaterCoolerTemperatureBackend is not yet implemented.") + + async def deactivate(self): + raise NotImplementedError("HamiltonHeaterCoolerTemperatureBackend is not yet implemented.") diff --git a/pylabrobot/hamilton/heater_cooler/heater_cooler.py b/pylabrobot/hamilton/heater_cooler/heater_cooler.py new file mode 100644 index 00000000000..e3718881e67 --- /dev/null +++ b/pylabrobot/hamilton/heater_cooler/heater_cooler.py @@ -0,0 +1,53 @@ +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder + +from .backend import HamiltonHeaterCoolerDriver, HamiltonHeaterCoolerTemperatureBackend + + +class HamiltonHeaterCooler(PlateHolder, Device): + """Hamilton Heater Cooler (HHC): Peltier-based temperature controller. + + Connects to a STAR liquid handler via TCC RS-232 port. + Temperature range: 0 to 110 °C with active cooling. + + Hamilton cat. no.: 6601900-01 + """ + + def __init__( + self, + name: str, + device_number: int = 1, + size_x: float = 145.5, + size_y: float = 104.0, + size_z: float = 67.8, + child_location: Coordinate = Coordinate(x=11.5, y=8.0, z=67.8), + pedestal_size_z: float = 0, + category: str = "temperature_controller", + model: Optional[str] = None, + ): + driver = HamiltonHeaterCoolerDriver(device_number=device_number) + 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=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.driver: HamiltonHeaterCoolerDriver = driver + self.tc = TemperatureController(backend=HamiltonHeaterCoolerTemperatureBackend(driver)) + self._capabilities = [self.tc] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } diff --git a/pylabrobot/hamilton/heater_shaker/__init__.py b/pylabrobot/hamilton/heater_shaker/__init__.py new file mode 100644 index 00000000000..f4232453ffb --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/__init__.py @@ -0,0 +1,3 @@ +from .backend import HamiltonHeaterShakerBackend +from .box import HamiltonHeaterShakerBox +from .heater_shaker import HamiltonHeaterShaker diff --git a/pylabrobot/hamilton/heater_shaker/backend.py b/pylabrobot/hamilton/heater_shaker/backend.py new file mode 100644 index 00000000000..6fe5c8a4a87 --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/backend.py @@ -0,0 +1,137 @@ +import asyncio +import logging +import time +from enum import Enum +from typing import Dict, Literal, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.shaking import ShakerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.hamilton.usb.driver import HamiltonUSBDriver + +logger = logging.getLogger(__name__) + + +class PlateLockPosition(Enum): + LOCKED = 1 + UNLOCKED = 0 + + +class HamiltonHeaterShakerBackend( + ShakerBackend, HasContinuousShaking, TemperatureControllerBackend +): + """Backend for Hamilton Heater Shaker: combined shaking and temperature control.""" + + def __init__(self, driver: HamiltonUSBDriver, index: int) -> None: + self.driver = driver + self.index = index + + async def _send_command(self, command: str, **kwargs) -> str: + resp = await self.driver.send_command(module=f"T{self.index}", command=command, **kwargs) + return resp # type: ignore[return-value] + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + await self._send_command("SI") + await self._send_command("LI") + + # -- shaking -- + + async def shake(self, speed: float, duration: float, backend_params=None): + await self.start_shaking(speed=speed) + await asyncio.sleep(duration) + await self.stop_shaking() + + async def start_shaking( + self, + speed: float = 800, + direction: Literal[0, 1] = 0, + acceleration: int = 1_000, + timeout: Optional[float] = 30, + ): + await self.lock_plate() + + int_speed = int(speed) + if not (20 <= int_speed <= 2_000): + raise ValueError("Speed must be between 20 and 2_000") + if direction not in [0, 1]: + raise ValueError("Direction must be 0 or 1") + if not (500 <= acceleration <= 10_000): + raise ValueError("Acceleration must be between 500 and 10_000") + + logger.info( + "[HHS %d] start shaking: speed=%d rpm, direction=%d, acceleration=%d", + self.index, + int_speed, + direction, + acceleration, + ) + now = time.time() + while True: + speed_str = str(int_speed).zfill(4) + acceleration_str = str(acceleration).zfill(5) + await self._send_command("SB", st=direction, sv=speed_str, sr=acceleration_str) + if await self.request_is_shaking(): + break + if timeout is not None and time.time() - now > timeout: + logger.error( + "[HHS %d] failed to start shaking within %ss timeout", self.index, timeout + ) + raise TimeoutError("Failed to start shaking within timeout") + + async def stop_shaking(self): + logger.info("[HHS %d] stop shaking", self.index) + await self._send_command("SC") + await self._send_command("SW") + + async def request_is_shaking(self) -> bool: + response = await self._send_command("RD") + is_shaking = response.endswith("1") + logger.debug("[HHS %d] read shaking status: is_shaking=%s", self.index, is_shaking) + return is_shaking + + @property + def supports_locking(self) -> bool: + return True + + async def lock_plate(self): + await self._send_command("LP", lp=PlateLockPosition.LOCKED.value) + + async def unlock_plate(self): + await self._send_command("LP", lp=PlateLockPosition.UNLOCKED.value) + + # -- temperature -- + + @property + def supports_active_cooling(self) -> bool: + return False + + async def set_temperature(self, temperature: float): + if not (0 < temperature <= 105): + raise ValueError(f"Temperature must be between 0 (exclusive) and 105, got {temperature}") + logger.info("[HHS %d] set temperature: target=%.1f C", self.index, temperature) + temp_str = f"{round(10 * temperature):04d}" + await self._send_command("TA", ta=temp_str) + + async def _request_current_temperature(self) -> Dict[str, float]: + response = await self._send_command("RT") + response = response.split("rt")[1] + middle_temp = float(str(response).split(" ")[0].strip("+")) / 10 + edge_temp = float(str(response).split(" ")[1].strip("+")) / 10 + return {"middle": middle_temp, "edge": edge_temp} + + async def request_current_temperature(self) -> float: + response = await self._request_current_temperature() + temp = response["middle"] + logger.info("[HHS %d] read temperature: actual=%.1f C", self.index, temp) + return temp + + async def request_edge_temperature(self) -> float: + response = await self._request_current_temperature() + temp = response["edge"] + logger.info("[HHS %d] read edge temperature: actual=%.1f C", self.index, temp) + return temp + + async def deactivate(self): + logger.info("[HHS %d] deactivate temperature control", self.index) + await self._send_command("TO") diff --git a/pylabrobot/hamilton/heater_shaker/box.py b/pylabrobot/hamilton/heater_shaker/box.py new file mode 100644 index 00000000000..f504d418347 --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/box.py @@ -0,0 +1,36 @@ +from typing import Any, Optional + +from pylabrobot.hamilton.usb.driver import HamiltonUSBDriver + + +class HamiltonHeaterShakerBox(HamiltonUSBDriver): + """USB control box for Hamilton Heater Shaker devices.""" + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + ): + super().__init__( + id_product=0x8002, + device_address=device_address, + serial_number=serial_number, + ) + + @property + def module_id_length(self) -> int: + return 2 + + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + idx = resp.find("id") + if idx != -1: + id_str = resp[idx + 2 : idx + 6] + if id_str.isdigit(): + return int(id_str) + return None + + def check_fw_string_error(self, resp: str): + pass + + def _parse_response(self, resp: str, fmt: Any) -> dict: + return {"raw": resp} diff --git a/pylabrobot/hamilton/heater_shaker/heater_shaker.py b/pylabrobot/hamilton/heater_shaker/heater_shaker.py new file mode 100644 index 00000000000..493f45fce7c --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/heater_shaker.py @@ -0,0 +1,54 @@ +from typing import Optional, Union + +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder + +from .backend import HamiltonHeaterShakerBackend +from .box import HamiltonHeaterShakerBox + +TYPE_CHECKING = False +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + + +class HamiltonHeaterShaker(PlateHolder, Device): + """Hamilton Heater Shaker: combined temperature control and shaking.""" + + def __init__( + self, + name: str, + index: int, + driver: Union[HamiltonHeaterShakerBox, "STARDriver"], + size_x: float = 146.2, + size_y: float = 103.6, + size_z: float = 74.11, + child_location: Coordinate = Coordinate(x=9.66, y=9.22, z=74.11), + pedestal_size_z: float = 0, + category: str = "heating_shaking", + model: Optional[str] = None, + ): + backend = HamiltonHeaterShakerBackend(driver=driver, index=index) + 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=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.tc = TemperatureController(backend=backend) + self.shaker = Shaker(backend=backend) + self._capabilities = [self.tc, self.shaker] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } diff --git a/pylabrobot/hamilton/lh/__init__.py b/pylabrobot/hamilton/lh/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/lh/vantage/__init__.py b/pylabrobot/hamilton/lh/vantage/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/lh/vantage/liquid_classes.py b/pylabrobot/hamilton/lh/vantage/liquid_classes.py new file mode 100644 index 00000000000..2bce6d2b03b --- /dev/null +++ b/pylabrobot/hamilton/lh/vantage/liquid_classes.py @@ -0,0 +1,14003 @@ +from typing import Dict, Optional, Tuple + +from pylabrobot.hamilton.liquid_handlers.liquid_class import ( + HamiltonLiquidClass, +) +from pylabrobot.resources.liquid import Liquid + +vantage_mapping: Dict[ + Tuple[int, bool, bool, bool, Liquid, bool, bool], + HamiltonLiquidClass, +] = {} + + +def get_vantage_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 Vantage 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 form 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 vantage_mapping.get( + (tip_volume, is_core, is_tip, has_filter, liquid, jet, blow_out), + None, + ) + + +vantage_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, +) + + +vantage_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, +) + + +# +vantage_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, +) + + +# +vantage_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 +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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 +# +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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 +# +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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 +# +# +vantage_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, +) + + +# Evaluation +vantage_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 +vantage_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 +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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. +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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. +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +# +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +# +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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. +vantage_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 +# +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, # <-- candidates + 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, +) + + +vantage_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, # <-- candidates + 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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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. +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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) +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +vantage_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 +# +vantage_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 +# +vantage_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 +# +vantage_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 +# +# +vantage_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 +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +# +# +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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. +vantage_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 +# +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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 +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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 +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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. +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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, +) + + +vantage_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/hamilton/liquid_handlers/__init__.py b/pylabrobot/hamilton/liquid_handlers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/base.py b/pylabrobot/hamilton/liquid_handlers/base.py new file mode 100644 index 00000000000..f9bf5d7abfb --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/base.py @@ -0,0 +1,162 @@ +import logging +from abc import ABCMeta, abstractmethod +from typing import ( + Any, + List, + Optional, + Sequence, + Tuple, + Union, +) + +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.hamilton.usb.driver import HamiltonUSBDriver +from pylabrobot.resources import TipSpot +from pylabrobot.resources.hamilton import ( + HamiltonTip, + TipPickupMethod, + TipSize, +) + +PipettingOp = Union[Pickup, TipDrop, Aspiration, Dispense] + +logger = logging.getLogger(__name__) + + +class HamiltonLiquidHandler(HamiltonUSBDriver, metaclass=ABCMeta): + """ + Abstract base class for Hamilton liquid handling robot backends. + """ + + @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, + ): + super().__init__( + id_product=id_product, + device_address=device_address, + serial_number=serial_number, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + self._tth2tti: dict[int, int] = {} # hash to tip type index + + @property + @abstractmethod + def num_channels(self) -> int: + """The number of pipette channels present on the robot.""" + + async def stop(self): + self._tth2tti.clear() + await super().stop() + + deck: Any # Set by subclasses; used for coordinate calculations. + + def _ops_to_fw_positions( + self, ops: Sequence[PipettingOp], use_channels: List[int] + ) -> Tuple[List[int], List[int], List[bool]]: + """use_channels is a list of channels to use. STAR expects this in one-hot encoding. This is + method converts that, and creates a matching list of x and y positions.""" + if use_channels != sorted(use_channels): + raise ValueError("Channels must be sorted.") + + x_positions: List[int] = [] + y_positions: List[int] = [] + channels_involved: List[bool] = [] + for i, channel in enumerate(use_channels): + while channel > len(channels_involved): + channels_involved.append(False) + x_positions.append(0) + y_positions.append(0) + channels_involved.append(True) + + x_pos = ops[i].resource.get_location_wrt(self.deck, x="c", y="c", z="b").x + ops[i].offset.x + x_positions.append(round(x_pos * 10)) + + y_pos = ops[i].resource.get_location_wrt(self.deck, x="c", y="c", z="b").y + ops[i].offset.y + y_positions.append(round(y_pos * 10)) + + # check that the minimum d between any two y positions is >9mm + # O(n^2) search is not great but this is most readable, and the max size is 16, so it's fine. + for channel_idx1, (x1, y1) in enumerate(zip(x_positions, y_positions)): + for channel_idx2, (x2, y2) in enumerate(zip(x_positions, y_positions)): + if channel_idx1 == channel_idx2: + continue + if not channels_involved[channel_idx1] or not channels_involved[channel_idx2]: + continue + if x1 != x2: # channels not on the same column -> will be two operations on the machine + continue + if y1 != y2 and abs(y1 - y2) < 90: + raise ValueError( + f"Minimum distance between two y positions is <9mm: {y1}, {y2}" + f" (channel {channel_idx1} and {channel_idx2})" + ) + + if len(ops) > self.num_channels: + raise ValueError(f"Too many channels specified: {len(ops)} > {self.num_channels}") + + if len(x_positions) < self.num_channels: + # We do want to have a trailing zero on x_positions, y_positions, and channels_involved, for + # some reason, if the length < 8. + x_positions = x_positions + [0] + y_positions = y_positions + [0] + channels_involved = channels_involved + [False] + + return x_positions, y_positions, channels_involved + + @abstractmethod + 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 in firmware.""" + + async def request_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: + """Get a tip type table index for the tip. + + If the tip has previously been defined, used that index. Otherwise, define a new tip type. + """ + + tip_type_hash = hash(tip) + + if tip_type_hash not in self._tth2tti: + ttti = len(self._tth2tti) + 1 + if ttti > 99: + raise ValueError("Too many tip types defined.") + + await self.define_tip_needle( + tip_type_table_index=ttti, + has_filter=tip.has_filter, + tip_length=round((tip.total_tip_length - tip.fitting_depth) * 10), # in 0.1mm + maximum_tip_volume=round(tip.maximal_volume * 10), # in 0.1ul + tip_size=tip.tip_size, + pickup_method=tip.pickup_method, + ) + self._tth2tti[tip_type_hash] = ttti + + return self._tth2tti[tip_type_hash] + + def _get_hamilton_tip(self, tip_spots: List[TipSpot]) -> HamiltonTip: + """Get the single tip type for all tip spots. If it does not exist or is not a HamiltonTip, + raise an error.""" + tips = set(tip_spot.get_tip() for tip_spot in tip_spots) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + if len(tips) == 0: + raise ValueError("No tips specified.") + tip = tips.pop() + if not isinstance(tip, HamiltonTip): + raise ValueError(f"Tip {tip} is not a HamiltonTip.") + return tip diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/base.py b/pylabrobot/hamilton/liquid_handlers/liquid_class.py similarity index 100% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/base.py rename to pylabrobot/hamilton/liquid_handlers/liquid_class.py diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py new file mode 100644 index 00000000000..747d9457b91 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -0,0 +1,5 @@ +from .chatterbox import NimbusChatterboxDriver +from .door import NimbusDoor +from .driver import NimbusDriver +from .nimbus import Nimbus +from .pip_backend import NimbusPIPBackend diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py new file mode 100644 index 00000000000..b1846b15709 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -0,0 +1,70 @@ +"""NimbusChatterboxDriver: prints commands instead of sending them over TCP.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + +from .door import NimbusDoor +from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + + +class NimbusChatterboxDriver(NimbusDriver): + """Chatterbox driver for Nimbus. Simulates commands for testing without hardware. + + Inherits NimbusDriver but overrides setup/stop/send_command to skip TCP + and use canned addresses and responses instead. + """ + + def __init__(self, deck: NimbusDeck, num_channels: int = 8): + # Pass dummy host — Socket is created but never opened + super().__init__(deck=deck, host="chatterbox", port=2000) + self._num_channels = num_channels + + async def setup(self): + from .pip_backend import NimbusPIPBackend + + # Use canned addresses (skip TCP connection entirely) + pipette_address = Address(1, 1, 257) + self._nimbus_core_address = Address(1, 1, 48896) + door_address = Address(1, 1, 268) + + self.pip = NimbusPIPBackend( + driver=self, deck=self.deck, address=pipette_address, num_channels=self._num_channels + ) + self.door = NimbusDoor(driver=self, address=door_address) + + async def stop(self): + if self.door is not None: + await self.door._on_stop() + self.door = None + + async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: + logger.info(f"[Chatterbox] {command.__class__.__name__}") + + # Return canned responses for commands that need them + from .commands import ( + GetChannelConfiguration, + GetChannelConfiguration_1, + IsDoorLocked, + IsInitialized, + IsTipPresent, + ) + + if isinstance(command, GetChannelConfiguration_1): + return {"channels": self._num_channels} + if isinstance(command, IsInitialized): + return {"initialized": True} + if isinstance(command, IsTipPresent): + return {"tip_present": [False] * self._num_channels} + if isinstance(command, IsDoorLocked): + return {"locked": True} + if isinstance(command, GetChannelConfiguration): + return {"enabled": [False]} + return None diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py new file mode 100644 index 00000000000..33273fa73ce --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -0,0 +1,869 @@ +"""Hamilton Nimbus command classes and supporting types. + +This module contains all Nimbus-specific Hamilton protocol commands, including +tip management, initialization, door control, ADC, aspirate, and dispense. +""" + +from __future__ import annotations + +import enum +import logging +from typing import List + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import HoiParams, HoiParamsParser +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.resources import Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# TIP TYPE ENUM +# ============================================================================ + + +class NimbusTipType(enum.IntEnum): + """Hamilton Nimbus tip type enumeration. + + Maps tip type names to their integer values used in Hamilton protocol commands. + """ + + STANDARD_300UL = 0 # "300ul Standard Volume Tip" + STANDARD_300UL_FILTER = 1 # "300ul Standard Volume Tip with filter" + LOW_VOLUME_10UL = 2 # "10ul Low Volume Tip" + LOW_VOLUME_10UL_FILTER = 3 # "10ul Low Volume Tip with filter" + HIGH_VOLUME_1000UL = 4 # "1000ul High Volume Tip" + HIGH_VOLUME_1000UL_FILTER = 5 # "1000ul High Volume Tip with filter" + TIP_50UL = 22 # "50ul Tip" + TIP_50UL_FILTER = 23 # "50ul Tip with filter" + SLIM_CORE_300UL = 36 # "SLIM CO-RE Tip 300ul" + + +def _get_tip_type_from_tip(tip: Tip) -> int: + """Map Tip object characteristics to Hamilton tip type integer. + + Args: + tip: Tip object with volume and filter information. Must be a HamiltonTip. + + Returns: + Hamilton tip type integer value. + + Raises: + ValueError: If tip characteristics don't match any known tip type. + """ + + if not isinstance(tip, HamiltonTip): + raise ValueError("Tip must be a HamiltonTip to determine tip type.") + + if tip.tip_size == TipSize.LOW_VOLUME: # 10ul tip + return NimbusTipType.LOW_VOLUME_10UL_FILTER if tip.has_filter else NimbusTipType.LOW_VOLUME_10UL + + if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 60: # 50ul tip + return NimbusTipType.TIP_50UL_FILTER if tip.has_filter else NimbusTipType.TIP_50UL + + if tip.tip_size == TipSize.STANDARD_VOLUME: # 300ul tip + return NimbusTipType.STANDARD_300UL_FILTER if tip.has_filter else NimbusTipType.STANDARD_300UL + + if tip.tip_size == TipSize.HIGH_VOLUME: # 1000ul tip + return ( + NimbusTipType.HIGH_VOLUME_1000UL_FILTER + if tip.has_filter + else NimbusTipType.HIGH_VOLUME_1000UL + ) + + raise ValueError( + f"Cannot determine tip type for tip with volume {tip.maximal_volume}uL " + f"and filter={tip.has_filter}. No matching Hamilton tip type found." + ) + + +def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: + """Get default flow rate based on tip type. + + Defaults from Hamilton Nimbus: + - 1000 ul tip: 250 asp / 400 disp + - 300 and 50 ul tip: 100 asp / 180 disp + - 10 ul tip: 100 asp / 75 disp + + Args: + tip: Tip object to determine default flow rate for. + is_aspirate: True for aspirate, False for dispense. + + Returns: + Default flow rate in uL/s. + """ + tip_type = _get_tip_type_from_tip(tip) + + if tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): + return 250.0 if is_aspirate else 400.0 + + if tip_type in (NimbusTipType.LOW_VOLUME_10UL, NimbusTipType.LOW_VOLUME_10UL_FILTER): + return 100.0 if is_aspirate else 75.0 + + # 50 and 300 ul tips + return 100.0 if is_aspirate else 180.0 + + +# ============================================================================ +# COMMAND CLASSES +# ============================================================================ + + +class LockDoor(HamiltonCommand): + """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + + +class UnlockDoor(HamiltonCommand): + """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 2 + + +class IsDoorLocked(HamiltonCommand): + """Check if door is locked (DoorLock at 1:1:268, interface_id=1, command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 3 + action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsDoorLocked response.""" + parser = HoiParamsParser(data) + _, locked = parser.parse_next() + return {"locked": bool(locked)} + + +class PreInitializeSmart(HamiltonCommand): + """Pre-initialize smart command (Pipette at 1:1:257, interface_id=1, command_id=32).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 32 + + +class InitializeSmartRoll(HamiltonCommand): + """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 29 + + def __init__( + self, + dest: Address, + x_positions: List[int], + y_positions: List[int], + begin_tip_deposit_process: List[int], + end_tip_deposit_process: List[int], + z_position_at_end_of_a_command: List[int], + roll_distances: List[int], + ): + """Initialize InitializeSmartRoll command. + + Args: + dest: Destination address (NimbusCore) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + begin_tip_deposit_process: Z start positions in 0.01mm units + end_tip_deposit_process: Z stop positions in 0.01mm units + z_position_at_end_of_a_command: Z position at end of command in 0.01mm units + roll_distances: Roll distances in 0.01mm units + """ + super().__init__(dest) + self.x_positions = x_positions + self.y_positions = y_positions + self.begin_tip_deposit_process = begin_tip_deposit_process + self.end_tip_deposit_process = end_tip_deposit_process + self.z_position_at_end_of_a_command = z_position_at_end_of_a_command + self.roll_distances = roll_distances + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32_array(self.begin_tip_deposit_process) + .i32_array(self.end_tip_deposit_process) + .i32_array(self.z_position_at_end_of_a_command) + .i32_array(self.roll_distances) + ) + + +class IsInitialized(HamiltonCommand): + """Check if instrument is initialized (NimbusCore at 1:1:48896, interface_id=1, command_id=14).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 14 + action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsInitialized response.""" + parser = HoiParamsParser(data) + _, initialized = parser.parse_next() + return {"initialized": bool(initialized)} + + +class IsTipPresent(HamiltonCommand): + """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 16 + action_code = 0 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsTipPresent response - returns List[i16].""" + parser = HoiParamsParser(data) + # Parse array of i16 values representing tip presence per channel + _, tip_presence = parser.parse_next() + return {"tip_present": tip_presence} + + +class GetChannelConfiguration_1(HamiltonCommand): + """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 15 + action_code = 0 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse GetChannelConfiguration_1 response. + + Returns: (channels: u16, channel_types: List[i16]) + """ + parser = HoiParamsParser(data) + _, channels = parser.parse_next() + _, channel_types = parser.parse_next() + return {"channels": channels, "channel_types": channel_types} + + +class SetChannelConfiguration(HamiltonCommand): + """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 67 + + def __init__( + self, + dest: Address, + channel: int, + indexes: List[int], + enables: List[bool], + ): + """Initialize SetChannelConfiguration command. + + Args: + dest: Destination address (Pipette) + channel: Channel number (1-based) + indexes: List of configuration indexes (e.g., [1, 3, 4]) + 1: Tip Recognition, 2: Aspirate and clot monitoring pLLD, + 3: Aspirate monitoring with cLLD, 4: Clot monitoring with cLLD + enables: List of enable flags (e.g., [True, False, False, False]) + """ + super().__init__(dest) + self.channel = channel + self.indexes = indexes + self.enables = enables + + def build_parameters(self) -> HoiParams: + return HoiParams().u16(self.channel).i16_array(self.indexes).bool_array(self.enables) + + +class GetChannelConfiguration(HamiltonCommand): + """Get channel configuration command (Pipette at 1:1:257, interface_id=1, command_id=66).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 66 + action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + + def __init__( + self, + dest: Address, + channel: int, + indexes: List[int], + ): + """Initialize GetChannelConfiguration command. + + Args: + dest: Destination address (Pipette) + channel: Channel number (1-based) + indexes: List of configuration indexes (e.g., [2] for "Aspirate monitoring with cLLD") + """ + super().__init__(dest) + self.channel = channel + self.indexes = indexes + + def build_parameters(self) -> HoiParams: + return HoiParams().u16(self.channel).i16_array(self.indexes) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse GetChannelConfiguration response. + + Returns: { enabled: List[bool] } + """ + parser = HoiParamsParser(data) + _, enabled = parser.parse_next() + return {"enabled": enabled} + + +class Park(HamiltonCommand): + """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 3 + + +class PickupTips(HamiltonCommand): + """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + begin_tip_pick_up_process: List[int], + end_tip_pick_up_process: List[int], + tip_types: List[int], + ): + """Initialize PickupTips command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + begin_tip_pick_up_process: Z start positions in 0.01mm units + end_tip_pick_up_process: Z stop positions in 0.01mm units + tip_types: Tip type integers for each channel + """ + super().__init__(dest) + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.begin_tip_pick_up_process = begin_tip_pick_up_process + self.end_tip_pick_up_process = end_tip_pick_up_process + self.tip_types = tip_types + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.begin_tip_pick_up_process) + .i32_array(self.end_tip_pick_up_process) + .u16_array(self.tip_types) + ) + + +class DropTips(HamiltonCommand): + """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 5 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + begin_tip_deposit_process: List[int], + end_tip_deposit_process: List[int], + z_position_at_end_of_a_command: List[int], + default_waste: bool, + ): + """Initialize DropTips command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + begin_tip_deposit_process: Z start positions in 0.01mm units + end_tip_deposit_process: Z stop positions in 0.01mm units + z_position_at_end_of_a_command: Z position at end of command in 0.01mm units + default_waste: If True, drop to default waste (positions may be ignored) + """ + super().__init__(dest) + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.begin_tip_deposit_process = begin_tip_deposit_process + self.end_tip_deposit_process = end_tip_deposit_process + self.z_position_at_end_of_a_command = z_position_at_end_of_a_command + self.default_waste = default_waste + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.begin_tip_deposit_process) + .i32_array(self.end_tip_deposit_process) + .i32_array(self.z_position_at_end_of_a_command) + .bool_value(self.default_waste) + ) + + +class DropTipsRoll(HamiltonCommand): + """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 82 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + begin_tip_deposit_process: List[int], + end_tip_deposit_process: List[int], + z_position_at_end_of_a_command: List[int], + roll_distances: List[int], + ): + """Initialize DropTipsRoll command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + begin_tip_deposit_process: Z start positions in 0.01mm units + end_tip_deposit_process: Z stop positions in 0.01mm units + z_position_at_end_of_a_command: Z position at end of command in 0.01mm units + roll_distances: Roll distance for each channel in 0.01mm units + """ + super().__init__(dest) + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.begin_tip_deposit_process = begin_tip_deposit_process + self.end_tip_deposit_process = end_tip_deposit_process + self.z_position_at_end_of_a_command = z_position_at_end_of_a_command + self.roll_distances = roll_distances + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.begin_tip_deposit_process) + .i32_array(self.end_tip_deposit_process) + .i32_array(self.z_position_at_end_of_a_command) + .i32_array(self.roll_distances) + ) + + +class EnableADC(HamiltonCommand): + """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 43 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + ): + """Initialize EnableADC command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + """ + super().__init__(dest) + self.channels_involved = channels_involved + + def build_parameters(self) -> HoiParams: + return HoiParams().u16_array(self.channels_involved) + + +class DisableADC(HamiltonCommand): + """Disable ADC command (Pipette at 1:1:257, interface_id=1, command_id=44).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 44 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + ): + """Initialize DisableADC command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + """ + super().__init__(dest) + self.channels_involved = channels_involved + + def build_parameters(self) -> HoiParams: + return HoiParams().u16_array(self.channels_involved) + + +class Aspirate(HamiltonCommand): + """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 6 + + def __init__( + self, + dest: Address, + aspirate_type: List[int], + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + lld_search_height: List[int], + liquid_height: List[int], + immersion_depth: List[int], + surface_following_distance: List[int], + minimum_height: List[int], + clot_detection_height: List[int], + min_z_endpos: int, + swap_speed: List[int], + blow_out_air_volume: List[int], + pre_wetting_volume: List[int], + aspirate_volume: List[int], + transport_air_volume: List[int], + aspiration_speed: List[int], + settling_time: List[int], + mix_volume: List[int], + mix_cycles: List[int], + mix_position_from_liquid_surface: List[int], + mix_surface_following_distance: List[int], + mix_speed: List[int], + tube_section_height: List[int], + tube_section_ratio: List[int], + lld_mode: List[int], + gamma_lld_sensitivity: List[int], + dp_lld_sensitivity: List[int], + lld_height_difference: List[int], + tadm_enabled: bool, + limit_curve_index: List[int], + recording_mode: int, + ): + """Initialize Aspirate command. + + Args: + dest: Destination address (Pipette) + aspirate_type: Aspirate type for each channel (List[i16]) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + lld_search_height: LLD search height for each channel in 0.01mm units + liquid_height: Liquid height for each channel in 0.01mm units + immersion_depth: Immersion depth for each channel in 0.01mm units + surface_following_distance: Surface following distance for each channel in 0.01mm units + minimum_height: Minimum height for each channel in 0.01mm units + clot_detection_height: Clot detection height for each channel in 0.01mm units + min_z_endpos: Minimum Z end position in 0.01mm units + swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units + blow_out_air_volume: Blowout volume for each channel in 0.1uL units + pre_wetting_volume: Pre-wetting volume for each channel in 0.1uL units + aspirate_volume: Aspirate volume for each channel in 0.1uL units + transport_air_volume: Transport air volume for each channel in 0.1uL units + aspiration_speed: Aspirate speed for each channel in 0.1uL/s units + settling_time: Settling time for each channel in 0.1s units + mix_volume: Mix volume for each channel in 0.1uL units + mix_cycles: Mix cycles for each channel + mix_position_from_liquid_surface: Mix position from liquid surface in 0.01mm units + mix_surface_following_distance: Mix follow distance in 0.01mm units + mix_speed: Mix speed for each channel in 0.1uL/s units + tube_section_height: Tube section height for each channel in 0.01mm units + tube_section_ratio: Tube section ratio for each channel + lld_mode: LLD mode for each channel (List[i16]) + gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) + dp_lld_sensitivity: DP LLD sensitivity for each channel (List[i16]) + lld_height_difference: LLD height difference for each channel in 0.01mm units + tadm_enabled: TADM enabled flag + limit_curve_index: Limit curve index for each channel + recording_mode: Recording mode (u16) + """ + super().__init__(dest) + self.aspirate_type = aspirate_type + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.lld_search_height = lld_search_height + self.liquid_height = liquid_height + self.immersion_depth = immersion_depth + self.surface_following_distance = surface_following_distance + self.minimum_height = minimum_height + self.clot_detection_height = clot_detection_height + self.min_z_endpos = min_z_endpos + self.swap_speed = swap_speed + self.blow_out_air_volume = blow_out_air_volume + self.pre_wetting_volume = pre_wetting_volume + self.aspirate_volume = aspirate_volume + self.transport_air_volume = transport_air_volume + self.aspiration_speed = aspiration_speed + self.settling_time = settling_time + self.mix_volume = mix_volume + self.mix_cycles = mix_cycles + self.mix_position_from_liquid_surface = mix_position_from_liquid_surface + self.mix_surface_following_distance = mix_surface_following_distance + self.mix_speed = mix_speed + self.tube_section_height = tube_section_height + self.tube_section_ratio = tube_section_ratio + self.lld_mode = lld_mode + self.gamma_lld_sensitivity = gamma_lld_sensitivity + self.dp_lld_sensitivity = dp_lld_sensitivity + self.lld_height_difference = lld_height_difference + self.tadm_enabled = tadm_enabled + self.limit_curve_index = limit_curve_index + self.recording_mode = recording_mode + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .i16_array(self.aspirate_type) + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.lld_search_height) + .i32_array(self.liquid_height) + .i32_array(self.immersion_depth) + .i32_array(self.surface_following_distance) + .i32_array(self.minimum_height) + .i32_array(self.clot_detection_height) + .i32(self.min_z_endpos) + .u32_array(self.swap_speed) + .u32_array(self.blow_out_air_volume) + .u32_array(self.pre_wetting_volume) + .u32_array(self.aspirate_volume) + .u32_array(self.transport_air_volume) + .u32_array(self.aspiration_speed) + .u32_array(self.settling_time) + .u32_array(self.mix_volume) + .u32_array(self.mix_cycles) + .i32_array(self.mix_position_from_liquid_surface) + .i32_array(self.mix_surface_following_distance) + .u32_array(self.mix_speed) + .i32_array(self.tube_section_height) + .i32_array(self.tube_section_ratio) + .i16_array(self.lld_mode) + .i16_array(self.gamma_lld_sensitivity) + .i16_array(self.dp_lld_sensitivity) + .i32_array(self.lld_height_difference) + .bool_value(self.tadm_enabled) + .u32_array(self.limit_curve_index) + .u16(self.recording_mode) + ) + + +class Dispense(HamiltonCommand): + """Dispense command (Pipette at 1:1:257, interface_id=1, command_id=7).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 7 + + def __init__( + self, + dest: Address, + dispense_type: List[int], + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + lld_search_height: List[int], + liquid_height: List[int], + immersion_depth: List[int], + surface_following_distance: List[int], + minimum_height: List[int], + min_z_endpos: int, + swap_speed: List[int], + transport_air_volume: List[int], + dispense_volume: List[int], + stop_back_volume: List[int], + blow_out_air_volume: List[int], + dispense_speed: List[int], + cut_off_speed: List[int], + settling_time: List[int], + mix_volume: List[int], + mix_cycles: List[int], + mix_position_from_liquid_surface: List[int], + mix_surface_following_distance: List[int], + mix_speed: List[int], + side_touch_off_distance: int, + dispense_offset: List[int], + tube_section_height: List[int], + tube_section_ratio: List[int], + lld_mode: List[int], + gamma_lld_sensitivity: List[int], + tadm_enabled: bool, + limit_curve_index: List[int], + recording_mode: int, + ): + """Initialize Dispense command. + + Args: + dest: Destination address (Pipette) + dispense_type: Dispense type for each channel (List[i16]) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + lld_search_height: LLD search height for each channel in 0.01mm units + liquid_height: Liquid height for each channel in 0.01mm units + immersion_depth: Immersion depth for each channel in 0.01mm units + surface_following_distance: Surface following distance for each channel in 0.01mm units + minimum_height: Minimum height for each channel in 0.01mm units + min_z_endpos: Minimum Z end position in 0.01mm units + swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units + transport_air_volume: Transport air volume for each channel in 0.1uL units + dispense_volume: Dispense volume for each channel in 0.1uL units + stop_back_volume: Stop back volume for each channel in 0.1uL units + blow_out_air_volume: Blowout volume for each channel in 0.1uL units + dispense_speed: Dispense speed for each channel in 0.1uL/s units + cut_off_speed: Cut off speed for each channel in 0.1uL/s units + settling_time: Settling time for each channel in 0.1s units + mix_volume: Mix volume for each channel in 0.1uL units + mix_cycles: Mix cycles for each channel + mix_position_from_liquid_surface: Mix position from liquid surface in 0.01mm units + mix_surface_following_distance: Mix follow distance in 0.01mm units + mix_speed: Mix speed for each channel in 0.1uL/s units + side_touch_off_distance: Side touch off distance in 0.01mm units + dispense_offset: Dispense offset for each channel in 0.01mm units + tube_section_height: Tube section height for each channel in 0.01mm units + tube_section_ratio: Tube section ratio for each channel + lld_mode: LLD mode for each channel (List[i16]) + gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) + tadm_enabled: TADM enabled flag + limit_curve_index: Limit curve index for each channel + recording_mode: Recording mode (u16) + """ + super().__init__(dest) + self.dispense_type = dispense_type + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.lld_search_height = lld_search_height + self.liquid_height = liquid_height + self.immersion_depth = immersion_depth + self.surface_following_distance = surface_following_distance + self.minimum_height = minimum_height + self.min_z_endpos = min_z_endpos + self.swap_speed = swap_speed + self.transport_air_volume = transport_air_volume + self.dispense_volume = dispense_volume + self.stop_back_volume = stop_back_volume + self.blow_out_air_volume = blow_out_air_volume + self.dispense_speed = dispense_speed + self.cut_off_speed = cut_off_speed + self.settling_time = settling_time + self.mix_volume = mix_volume + self.mix_cycles = mix_cycles + self.mix_position_from_liquid_surface = mix_position_from_liquid_surface + self.mix_surface_following_distance = mix_surface_following_distance + self.mix_speed = mix_speed + self.side_touch_off_distance = side_touch_off_distance + self.dispense_offset = dispense_offset + self.tube_section_height = tube_section_height + self.tube_section_ratio = tube_section_ratio + self.lld_mode = lld_mode + self.gamma_lld_sensitivity = gamma_lld_sensitivity + self.tadm_enabled = tadm_enabled + self.limit_curve_index = limit_curve_index + self.recording_mode = recording_mode + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .i16_array(self.dispense_type) + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.lld_search_height) + .i32_array(self.liquid_height) + .i32_array(self.immersion_depth) + .i32_array(self.surface_following_distance) + .i32_array(self.minimum_height) + .i32(self.min_z_endpos) + .u32_array(self.swap_speed) + .u32_array(self.transport_air_volume) + .u32_array(self.dispense_volume) + .u32_array(self.stop_back_volume) + .u32_array(self.blow_out_air_volume) + .u32_array(self.dispense_speed) + .u32_array(self.cut_off_speed) + .u32_array(self.settling_time) + .u32_array(self.mix_volume) + .u32_array(self.mix_cycles) + .i32_array(self.mix_position_from_liquid_surface) + .i32_array(self.mix_surface_following_distance) + .u32_array(self.mix_speed) + .i32(self.side_touch_off_distance) + .i32_array(self.dispense_offset) + .i32_array(self.tube_section_height) + .i32_array(self.tube_section_ratio) + .i16_array(self.lld_mode) + .i16_array(self.gamma_lld_sensitivity) + .bool_value(self.tadm_enabled) + .u32_array(self.limit_curve_index) + .u16(self.recording_mode) + ) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/door.py b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py new file mode 100644 index 00000000000..363bb93b6ca --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py @@ -0,0 +1,56 @@ +"""NimbusDoor: door control subsystem for Hamilton Nimbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pylabrobot.hamilton.tcp.packets import Address + +from .commands import IsDoorLocked, LockDoor, UnlockDoor + +if TYPE_CHECKING: + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + + +class NimbusDoor: + """Controls the door on a Hamilton Nimbus. + + Plain helper class (not a CapabilityBackend), following the STARCover pattern. + Owned by NimbusDriver, exposed via convenience methods on the Nimbus device. + """ + + def __init__(self, driver: "NimbusDriver", address: Address): + self.driver = driver + self.address = address + + async def _on_setup(self): + """Lock door on setup if available.""" + try: + if not await self.is_locked(): + await self.lock() + else: + logger.info("Door already locked") + except Exception as e: + logger.warning(f"Door operations skipped during setup: {e}") + + async def _on_stop(self): + pass + + async def is_locked(self) -> bool: + """Check if the door is locked.""" + status = await self.driver.send_command(IsDoorLocked(self.address)) + assert status is not None, "IsDoorLocked command returned None" + return bool(status["locked"]) + + async def lock(self) -> None: + """Lock the door.""" + await self.driver.send_command(LockDoor(self.address)) + logger.info("Door locked successfully") + + async def unlock(self) -> None: + """Unlock the door.""" + await self.driver.send_command(UnlockDoor(self.address)) + logger.info("Door unlocked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py new file mode 100644 index 00000000000..28916fc2e3d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -0,0 +1,143 @@ +"""NimbusDriver: TCP-based driver for Hamilton Nimbus liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import Dict, Optional + +from pylabrobot.hamilton.liquid_handlers.tcp_base import HamiltonTCPHandler +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + +from .commands import ( + GetChannelConfiguration_1, + Park, +) +from .door import NimbusDoor +from .pip_backend import NimbusPIPBackend + +logger = logging.getLogger(__name__) + + +class NimbusDriver(HamiltonTCPHandler): + """Driver for Hamilton Nimbus liquid handlers. + + Handles TCP communication, hardware discovery via introspection, and + manages the PIP backend and door subsystem. + """ + + def __init__( + self, + deck: NimbusDeck, + host: str, + port: int = 2000, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + ) + + self.deck = deck + self._nimbus_core_address: Optional[Address] = None + + self.pip: NimbusPIPBackend # set in setup() + self.door: Optional[NimbusDoor] = None # set in setup() if available + + @property + def nimbus_core_address(self) -> Address: + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore address not discovered. Call setup() first.") + return self._nimbus_core_address + + async def setup(self): + """Initialize connection, discover hardware, and create backends.""" + assert self.deck is not None, "NimbusDriver requires a deck before setup()" + # TCP connection + Protocol 7 + Protocol 3 + root discovery + await super().setup() + + # Discover instrument objects via introspection + addresses = await self._discover_instrument_objects() + + pipette_address = addresses.get("Pipette") + door_address = addresses.get("DoorLock") + + if pipette_address is None: + raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") + + # Query channel configuration + config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) + assert config is not None, "GetChannelConfiguration_1 command returned None" + num_channels = config["channels"] + logger.info(f"Channel configuration: {num_channels} channels") + + # Create backends — each object stores its own address and state + self.pip = NimbusPIPBackend( + driver=self, deck=self.deck, address=pipette_address, num_channels=num_channels + ) + + if door_address is not None: + self.door = NimbusDoor(driver=self, address=door_address) + + # Initialize subsystems + if self.door is not None: + await self.door._on_setup() + + async def stop(self): + """Stop driver and close connection.""" + if self.door is not None: + await self.door._on_stop() + await super().stop() + self.door = None + + async def _discover_instrument_objects(self) -> Dict[str, Address]: + """Discover instrument-specific objects using introspection. + + Returns: + Dictionary mapping object names (e.g. "Pipette", "DoorLock") to their addresses. + """ + introspection = HamiltonIntrospection(self) + addresses: Dict[str, Address] = {} + + root_objects = self._discovered_objects.get("root", []) + if not root_objects: + logger.warning("No root objects discovered") + return addresses + + nimbus_core_addr = root_objects[0] + self._nimbus_core_address = nimbus_core_addr + + try: + core_info = await introspection.get_object(nimbus_core_addr) + + for i in range(core_info.subobject_count): + try: + sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) + sub_info = await introspection.get_object(sub_addr) + addresses[sub_info.name] = sub_addr + logger.info(f"Found {sub_info.name} at {sub_addr}") + except Exception as e: + logger.debug(f"Failed to get subobject {i}: {e}") + + except Exception as e: + logger.warning(f"Failed to discover instrument objects: {e}") + + if "DoorLock" not in addresses: + logger.info("DoorLock not available on this instrument") + + return addresses + + async def park(self): + """Park the instrument.""" + await self.send_command(Park(self.nimbus_core_address)) + logger.info("Instrument parked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py new file mode 100644 index 00000000000..9d7a14be5ed --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -0,0 +1,91 @@ +"""Nimbus device: wires NimbusDriver backends to PIP capability frontend.""" + +from typing import Optional + +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + +from .chatterbox import NimbusChatterboxDriver +from .driver import NimbusDriver + + +class Nimbus(Device): + """Hamilton Nimbus liquid handler. + + User-facing device that wires the PIP capability frontend to the + NimbusDriver's PIP backend after hardware discovery during setup(). + """ + + def __init__( + self, + deck: NimbusDeck, + chatterbox: bool = False, + host: Optional[str] = None, + port: int = 2000, + ): + if chatterbox: + driver: NimbusDriver = NimbusChatterboxDriver(deck=deck) + else: + if not host: + raise ValueError("host must be provided when chatterbox is False.") + driver = NimbusDriver(deck=deck, host=host, port=port) + super().__init__(driver=driver) + self.driver: NimbusDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + + async def setup(self): + """Initialize the Nimbus instrument. + + Establishes the TCP connection, discovers hardware objects, queries channel + configuration and tip presence, locks the door (if available), conditionally + runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's + PIP backend. + """ + try: + await self.driver.setup() + + self.pip = PIP(backend=self.driver.pip, deck=self.deck) + self._capabilities = [self.pip] + await self.pip._on_setup() + self._setup_finished = True + except Exception: + await self.driver.stop() + raise + + async def stop(self): + """Tear down the Nimbus instrument. + + Stops all capabilities in reverse order and closes the driver connection. + """ + if not self._setup_finished: + return + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + + # -- Convenience methods delegating to driver/subsystems -------------------- + + async def park(self): + """Park the instrument.""" + await self.driver.park() + + async def lock_door(self): + """Lock the door.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + await self.driver.door.lock() + + async def unlock_door(self): + """Unlock the door.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + await self.driver.door.unlock() + + async def is_door_locked(self) -> bool: + """Check if the door is locked.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + return await self.driver.door.is_locked() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py new file mode 100644 index 00000000000..65b4b00cf09 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -0,0 +1,1059 @@ +"""NimbusPIPBackend: translates PIP operations into Nimbus firmware commands.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, TypeVar, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.resources import Tip +from pylabrobot.resources.container import Container +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.trash import Trash + +from .commands import ( + Aspirate, + Dispense as DispenseCommand, + DisableADC, + DropTips, + DropTipsRoll, + EnableADC, + GetChannelConfiguration, + InitializeSmartRoll, + IsInitialized, + IsTipPresent, + PickupTips, + SetChannelConfiguration, + _get_default_flow_rate, + _get_tip_type_from_tip, +) + +if TYPE_CHECKING: + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fill_in_defaults(val: Optional[List[T]], default: List[T]) -> List[T]: + """If val is None, return default. Otherwise validate length and fill None entries.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +# --------------------------------------------------------------------------- +# BackendParams dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class NimbusPIPPickUpTipsParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + + +@dataclass +class NimbusPIPDropTipsParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + default_waste: bool = False + z_position_at_end_of_a_command: Optional[float] = None + roll_distance: Optional[float] = None + + +@dataclass +class NimbusPIPAspirateParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + adc_enabled: bool = False + lld_mode: Optional[List[int]] = None + lld_search_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + dp_lld_sensitivity: Optional[List[int]] = None + settling_time: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + pre_wetting_volume: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + tadm_enabled: bool = False + + +@dataclass +class NimbusPIPDispenseParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + adc_enabled: bool = False + lld_mode: Optional[List[int]] = None + lld_search_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + settling_time: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + tadm_enabled: bool = False + cut_off_speed: Optional[List[float]] = None + stop_back_volume: Optional[List[float]] = None + side_touch_off_distance: float = 0.0 + dispense_offset: Optional[List[float]] = None + + +# --------------------------------------------------------------------------- +# NimbusPIPBackend +# --------------------------------------------------------------------------- + + +class NimbusPIPBackend(PIPBackend): + """PIP backend for Hamilton Nimbus liquid handlers. + + Translates abstract PIP operations (pick_up_tips, drop_tips, aspirate, dispense) + into Nimbus-specific Hamilton TCP commands. + """ + + def __init__( + self, + driver: "NimbusDriver", + deck: "NimbusDeck", + address: Optional["Address"] = None, + num_channels: int = 8, + traversal_height: float = 146.0, + ): + self.driver = driver + self.deck = deck + self.address = address + self._num_channels = num_channels + self.traversal_height = traversal_height + self._channel_configurations: Optional[dict] = None + + @property + def num_channels(self) -> int: + return self._num_channels + + @property + def pipette_address(self) -> Address: + if self.address is None: + raise RuntimeError("Pipette address not set. Call setup() first.") + return self.address + + async def _on_setup(self): + """Initialize SmartRoll if not already initialized.""" + # Query initialization status + init_status = await self.driver.send_command(IsInitialized(self.driver.nimbus_core_address)) + assert init_status is not None + is_initialized = init_status.get("initialized", False) + + if not is_initialized: + await self._initialize_smart_roll() + else: + logger.info("Instrument already initialized, skipping SmartRoll init") + + async def _on_stop(self): + pass + + async def _initialize_smart_roll(self): + """Configure channels and initialize SmartRoll with waste positions.""" + # Set channel configuration for each channel + for channel in range(1, self.num_channels + 1): + await self.driver.send_command( + SetChannelConfiguration( + dest=self.pipette_address, + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) + ) + logger.info(f"Channel configuration set for {self.num_channels} channels") + + # Initialize SmartRoll using waste positions + all_channels = list(range(self.num_channels)) + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._build_waste_position_params(use_channels=all_channels) + + await self.driver.send_command( + InitializeSmartRoll( + dest=self.driver.nimbus_core_address, + x_positions=x_positions_full, + y_positions=y_positions_full, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + ) + logger.info("NimbusCore initialized with InitializeSmartRoll successfully") + + # --------------------------------------------------------------------------- + # Channel fill helper + # --------------------------------------------------------------------------- + + def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: + """Returns a full-length list of size `num_channels` where positions in `use_channels` + are filled from `values` in order; all others are `default`.""" + if len(values) != len(use_channels): + raise ValueError( + f"values and channels must have same length (got {len(values)} vs {len(use_channels)})" + ) + for ch in use_channels: + if ch < 0 or ch >= self.num_channels: + raise ValueError( + f"Channel index {ch} out of range for {self.num_channels}-channel instrument" + ) + out = [default] * self.num_channels + for ch, v in zip(use_channels, values): + out[ch] = v + return out + + # --------------------------------------------------------------------------- + # Coordinate helpers + # --------------------------------------------------------------------------- + + def _compute_ops_xy_locations( + self, ops: Sequence, use_channels: List[int] + ) -> Tuple[List[int], List[int]]: + """Compute X and Y positions in Hamilton coordinates for the given operations.""" + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + x_positions_mm: List[float] = [] + y_positions_mm: List[float] = [] + + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + final_location = abs_location + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(final_location) + x_positions_mm.append(hamilton_coord.x) + y_positions_mm.append(hamilton_coord.y) + + x_positions = [round(x * 100) for x in x_positions_mm] + y_positions = [round(y * 100) for y in y_positions_mm] + + x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) + y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) + + return x_positions_full, y_positions_full + + def _compute_tip_handling_parameters( + self, + ops: Sequence, + use_channels: List[int], + use_fixed_offset: bool = False, + fixed_offset_mm: float = 10.0, + ) -> Tuple[List[int], List[int]]: + """Calculate Z positions for tip pickup/drop operations. + + Pickup (use_fixed_offset=False): Z based on tip length. + Drop (use_fixed_offset=True): Z based on fixed offset. + + Returns: (begin_position, end_position) in 0.01mm units, full num_channels arrays. + """ + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + z_positions_mm: List[float] = [] + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + z_positions_mm.append(hamilton_coord.z) + + max_z_hamilton = max(z_positions_mm) + + if use_fixed_offset: + begin_position_mm = max_z_hamilton + fixed_offset_mm + end_position_mm = max_z_hamilton + 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_position_mm = max_z_hamilton + max_total_tip_length + end_position_mm = max_z_hamilton + max_tip_length + + begin_position = [round(begin_position_mm * 100)] * len(ops) + end_position = [round(end_position_mm * 100)] * len(ops) + + begin_position_full = self._fill_by_channels(begin_position, use_channels, default=0) + end_position_full = self._fill_by_channels(end_position, use_channels, default=0) + + return begin_position_full, end_position_full + + def _build_waste_position_params( + self, + use_channels: List[int], + z_position_at_end_of_a_command: Optional[float] = None, + roll_distance: Optional[float] = None, + ) -> Tuple[List[int], List[int], List[int], List[int], List[int], List[int]]: + """Build waste position parameters for InitializeSmartRoll or DropTipsRoll.""" + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + x_positions_mm: List[float] = [] + y_positions_mm: List[float] = [] + z_positions_mm: List[float] = [] + + for channel_idx in use_channels: + if not hasattr(self.deck, "waste_type") or self.deck.waste_type is None: + raise RuntimeError( + f"Deck does not have waste_type attribute. " + f"Cannot determine waste position for channel {channel_idx}." + ) + waste_pos_name = f"{self.deck.waste_type}_{channel_idx + 1}" + waste_pos = self.deck.get_resource(waste_pos_name) + abs_location = waste_pos.get_location_wrt(self.deck) + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + + x_positions_mm.append(hamilton_coord.x) + y_positions_mm.append(hamilton_coord.y) + z_positions_mm.append(hamilton_coord.z) + + x_positions = [round(x * 100) for x in x_positions_mm] + y_positions = [round(y * 100) for y in y_positions_mm] + + max_z_hamilton = max(z_positions_mm) + z_start_absolute_mm = max_z_hamilton + 4.0 + z_stop_absolute_mm = max_z_hamilton + + if z_position_at_end_of_a_command is None: + z_position_at_end_of_a_command = self.traversal_height + if roll_distance is None: + roll_distance = 9.0 + + begin_tip_deposit_process = [round(z_start_absolute_mm * 100)] * len(use_channels) + end_tip_deposit_process = [round(z_stop_absolute_mm * 100)] * len(use_channels) + z_position_at_end_list = [round(z_position_at_end_of_a_command * 100)] * len(use_channels) + roll_distances = [round(roll_distance * 100)] * len(use_channels) + + x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) + y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) + begin_full = self._fill_by_channels(begin_tip_deposit_process, use_channels, default=0) + end_full = self._fill_by_channels(end_tip_deposit_process, use_channels, default=0) + z_end_full = self._fill_by_channels(z_position_at_end_list, use_channels, default=0) + roll_full = self._fill_by_channels(roll_distances, use_channels, default=0) + + return x_positions_full, y_positions_full, begin_full, end_full, z_end_full, roll_full + + # --------------------------------------------------------------------------- + # PIPBackend interface + # --------------------------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + tip_status = await self.driver.send_command(IsTipPresent(self.pipette_address)) + assert tip_status is not None, "IsTipPresent command returned None" + tip_present = tip_status.get("tip_present", []) + return [bool(v) for v in tip_present] + + 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 + if channel_idx >= self._num_channels: + return False + return True + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from the specified resource. + + Z positions are calculated from resource locations and tip properties: + - begin_tip_pick_up_process: max(resource Z) + max(tip total_tip_length) + - end_tip_pick_up_process: max(resource Z) + max(tip total_tip_length - fitting_depth) + + Checks tip presence before pickup and raises if channels already have tips. + + Args: + ops: List of Pickup operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPPickUpTipsParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``, typically 146.0 mm). + + Raises: + RuntimeError: If channels already have tips mounted. + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPPickUpTipsParams) + else NimbusPIPPickUpTipsParams() + ) + + # Check tip presence before picking up + try: + tip_present = await self.request_tip_presence() + channels_with_tips = [ + i for i, present in enumerate(tip_present) if i in use_channels and present + ] + if channels_with_tips: + raise RuntimeError( + f"Cannot pick up tips: channels {channels_with_tips} already have tips mounted." + ) + except RuntimeError: + raise + except Exception as e: + logger.warning(f"Could not check tip presence before pickup: {e}") + + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + begin_tip_pick_up_process, end_tip_pick_up_process = self._compute_tip_handling_parameters( + ops, use_channels + ) + + channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] + + tip_types = [_get_tip_type_from_tip(op.tip) for op in ops] + tip_types_full = self._fill_by_channels(tip_types, use_channels, default=0) + + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + command = PickupTips( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + tip_types=tip_types_full, + ) + + await self.driver.send_command(command) + logger.info(f"Picked up tips on channels {use_channels}") + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to the specified resource. + + Auto-detects waste positions and uses the appropriate firmware command: + - If resource is a Trash, uses **DropTipsRoll** (roll-off into waste chute). + - Otherwise, uses **DropTips** (return tips to a tip rack). + + Z positions are calculated from resource locations: + - Waste positions: Z start/stop from deck waste coordinates via ``_build_waste_position_params``. + - Regular resources: Fixed offset (max_z + 10 mm start, max_z stop) -- independent of tip + length because the tip is already mounted on the pipette. + + Cannot mix waste and regular resources in a single call. + + Args: + ops: List of TipDrop operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPDropTipsParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - default_waste: For DropTips command, if True the instrument drops to the + default waste position (default: False). + - z_position_at_end_of_a_command: Z final position in mm, absolute + (default: traversal height). + - roll_distance: Roll distance in mm for DropTipsRoll (default: 9.0 mm). + + Raises: + ValueError: If operations mix waste and regular resources. + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDropTipsParams) + else NimbusPIPDropTipsParams() + ) + + # Check if resources are waste positions + is_waste_positions = [isinstance(op.resource, Trash) for op in ops] + all_waste = all(is_waste_positions) + all_regular = not any(is_waste_positions) + + if not (all_waste or all_regular): + raise ValueError( + "Cannot mix waste positions and regular resources in a single drop_tips call." + ) + + channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] + + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + command: Union[DropTips, DropTipsRoll] + + if all_waste: + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._build_waste_position_params( + use_channels=use_channels, + z_position_at_end_of_a_command=params.z_position_at_end_of_a_command, + roll_distance=params.roll_distance, + ) + + command = DropTipsRoll( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + else: + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + begin_tip_deposit_process, end_tip_deposit_process = self._compute_tip_handling_parameters( + ops, use_channels, use_fixed_offset=True + ) + + z_end = params.z_position_at_end_of_a_command + if z_end is None: + z_end = traverse_height + z_position_at_end_list = [round(z_end * 100)] * len(ops) + z_position_at_end_full = self._fill_by_channels( + z_position_at_end_list, use_channels, default=0 + ) + + command = DropTips( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + z_position_at_end_of_a_command=z_position_at_end_full, + default_waste=params.default_waste, + ) + + await self.driver.send_command(command) + logger.info(f"Dropped tips on channels {use_channels}") + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified resource. + + Volumes, flow rates, blow-out air volumes, and mix parameters are taken from the + ``Aspiration`` operations. Hardware-level parameters are set via ``backend_params``. + + Args: + ops: List of Aspiration operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPAspirateParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - adc_enabled: Enable Automatic Drip Control (default: False). + - lld_mode: LLD mode per channel -- 0=OFF, 1=cLLD, 2=pLLD, 3=DUAL (default: [0]*n). + - lld_search_height: **Relative offset** from well bottom (mm) where LLD search + starts. The instrument adds this to minimum_height internally. + If None, defaults to the well's size_z (i.e. search from the top of the well). + - immersion_depth: Depth to submerge below liquid surface (mm, default: [0.0]*n). + - surface_following_distance: Distance to follow liquid surface during aspiration + (mm, default: [0.0]*n). + - gamma_lld_sensitivity: Gamma LLD sensitivity, 1-4 (default: [0]*n). + - dp_lld_sensitivity: Differential-pressure LLD sensitivity, 1-4 (default: [0]*n). + - settling_time: Settling time after aspiration (s, default: [1.0]*n). + - transport_air_volume: Transport air volume (uL, default: [5.0]*n). + - pre_wetting_volume: Pre-wetting volume (uL, default: [0.0]*n). + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). + - mix_position_from_liquid_surface: Mix position offset from liquid surface + (mm, default: [0.0]*n). + - limit_curve_index: Limit curve index (default: [0]*n). + - tadm_enabled: Enable TADM (Total Aspiration and Dispense Monitoring) + (default: False). + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPAspirateParams) + else NimbusPIPAspirateParams() + ) + + n = len(ops) + + channels_involved = [0] * self.num_channels + for channel_idx in use_channels: + channels_involved[channel_idx] = 1 + + # ADC control + if params.adc_enabled: + await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + else: + await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + + # Query channel configurations + if self._channel_configurations is None: + self._channel_configurations = {} + for channel_idx in use_channels: + channel_num = channel_idx + 1 + try: + config = await self.driver.send_command( + GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + ) + assert config is not None + enabled = config["enabled"][0] if config["enabled"] else False + if channel_num not in self._channel_configurations: + self._channel_configurations[channel_num] = {} + self._channel_configurations[channel_num][2] = enabled + except Exception as e: + logger.warning(f"Failed to get channel config for channel {channel_num}: {e}") + + # Compute XY positions + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + + # Traverse height + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + deck = self.deck + + # Well bottoms + well_bottoms = [] + for op in ops: + abs_location = op.resource.get_location_wrt(deck) + op.offset + if isinstance(op.resource, Container): + abs_location.z += op.resource.material_z_thickness + hamilton_coord = deck.to_hamilton_coordinate(abs_location) + well_bottoms.append(hamilton_coord.z) + + liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + + lld_search_height = params.lld_search_height + if lld_search_height is None: + lld_search_height = [op.resource.get_absolute_size_z() for op in ops] + + minimum_heights_mm = well_bottoms.copy() + + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate if op.flow_rate is not None else _get_default_flow_rate(op.tip, is_aspirate=True) + for op in ops + ] + blow_out_air_volumes = [ + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops + ] + + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ + op.mix.flow_rate + if op.mix is not None + else ( + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=True) + ) + for op in ops + ] + + # Advanced parameters + lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) + immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) + surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) + gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) + dp_lld_sensitivity = _fill_in_defaults(params.dp_lld_sensitivity, [0] * n) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + pre_wetting_volume = _fill_in_defaults(params.pre_wetting_volume, [0.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) + mix_position_from_liquid_surface = _fill_in_defaults( + params.mix_position_from_liquid_surface, [0.0] * n + ) + limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) + + # Unit conversions + aspirate_volumes = [round(vol * 10) for vol in volumes] + blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] + aspiration_speeds = [round(fr * 10) for fr in flow_rates] + lld_search_height_units = [round(h * 100) for h in lld_search_height] + liquid_height_units = [round(h * 100) for h in liquid_heights_mm] + immersion_depth_units = [round(d * 100) for d in immersion_depth] + surface_following_distance_units = [round(d * 100) for d in surface_following_distance] + minimum_height_units = [round(z * 100) for z in minimum_heights_mm] + settling_time_units = [round(t * 10) for t in settling_time] + transport_air_volume_units = [round(v * 10) for v in transport_air_volume] + pre_wetting_volume_units = [round(v * 10) for v in pre_wetting_volume] + swap_speed_units = [round(s * 10) for s in swap_speed] + mix_volume_units = [round(v * 10) for v in mix_volume] + mix_speed_units = [round(s * 10) for s in mix_speed] + mix_position_from_liquid_surface_units = [ + round(p * 100) for p in mix_position_from_liquid_surface + ] + + # Build full-channel arrays + aspirate_volumes_full = self._fill_by_channels(aspirate_volumes, use_channels, default=0) + blow_out_air_volumes_full = self._fill_by_channels( + blow_out_air_volumes_units, use_channels, default=0 + ) + aspiration_speeds_full = self._fill_by_channels(aspiration_speeds, use_channels, default=0) + lld_search_height_full = self._fill_by_channels( + lld_search_height_units, use_channels, default=0 + ) + liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) + immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) + surface_following_distance_full = self._fill_by_channels( + surface_following_distance_units, use_channels, default=0 + ) + minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) + settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) + transport_air_volume_full = self._fill_by_channels( + transport_air_volume_units, use_channels, default=0 + ) + pre_wetting_volume_full = self._fill_by_channels( + pre_wetting_volume_units, use_channels, default=0 + ) + swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) + mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) + mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) + mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) + mix_position_from_liquid_surface_full = self._fill_by_channels( + mix_position_from_liquid_surface_units, use_channels, default=0 + ) + gamma_lld_sensitivity_full = self._fill_by_channels( + gamma_lld_sensitivity, use_channels, default=0 + ) + dp_lld_sensitivity_full = self._fill_by_channels(dp_lld_sensitivity, use_channels, default=0) + limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) + lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) + + # Default values for remaining parameters + aspirate_type = [0] * self.num_channels + clot_detection_height = [0] * self.num_channels + min_z_endpos = traverse_height_units + mix_surface_following_distance = [0] * self.num_channels + tube_section_height = [0] * self.num_channels + tube_section_ratio = [0] * self.num_channels + lld_height_difference = [0] * self.num_channels + recording_mode = 0 + + command = Aspirate( + dest=self.pipette_address, + aspirate_type=aspirate_type, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + lld_search_height=lld_search_height_full, + liquid_height=liquid_height_full, + immersion_depth=immersion_depth_full, + surface_following_distance=surface_following_distance_full, + minimum_height=minimum_height_full, + clot_detection_height=clot_detection_height, + min_z_endpos=min_z_endpos, + swap_speed=swap_speed_full, + blow_out_air_volume=blow_out_air_volumes_full, + pre_wetting_volume=pre_wetting_volume_full, + aspirate_volume=aspirate_volumes_full, + transport_air_volume=transport_air_volume_full, + aspiration_speed=aspiration_speeds_full, + settling_time=settling_time_full, + mix_volume=mix_volume_full, + mix_cycles=mix_cycles_full, + mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, + mix_surface_following_distance=mix_surface_following_distance, + mix_speed=mix_speed_full, + tube_section_height=tube_section_height, + tube_section_ratio=tube_section_ratio, + lld_mode=lld_mode_full, + gamma_lld_sensitivity=gamma_lld_sensitivity_full, + dp_lld_sensitivity=dp_lld_sensitivity_full, + lld_height_difference=lld_height_difference, + tadm_enabled=params.tadm_enabled, + limit_curve_index=limit_curve_index_full, + recording_mode=recording_mode, + ) + + await self.driver.send_command(command) + logger.info(f"Aspirated on channels {use_channels}") + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified resource. + + Volumes, flow rates, blow-out air volumes, and mix parameters are taken from the + ``Dispense`` operations. Hardware-level parameters are set via ``backend_params``. + + Args: + ops: List of Dispense operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPDispenseParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - adc_enabled: Enable Automatic Drip Control (default: False). + - lld_mode: LLD mode per channel -- 0=OFF, 1=cLLD, 2=pLLD, 3=DUAL (default: [0]*n). + - lld_search_height: **Relative offset** from well bottom (mm) where LLD search + starts. If None, defaults to the well's size_z. + - immersion_depth: Depth to submerge below liquid surface (mm, default: [0.0]*n). + - surface_following_distance: Distance to follow liquid surface during dispense + (mm, default: [0.0]*n). + - gamma_lld_sensitivity: Gamma LLD sensitivity, 1-4 (default: [0]*n). + - settling_time: Settling time after dispense (s, default: [1.0]*n). + - transport_air_volume: Transport air volume (uL, default: [5.0]*n). + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). + - mix_position_from_liquid_surface: Mix position offset from liquid surface + (mm, default: [0.0]*n). + - limit_curve_index: Limit curve index (default: [0]*n). + - tadm_enabled: Enable TADM (default: False). + - cut_off_speed: Cut-off speed at end of dispense (uL/s, default: [25.0]*n). + - stop_back_volume: Stop-back volume to prevent dripping (uL, default: [0.0]*n). + - side_touch_off_distance: Side touch-off distance (mm, default: 0.0). + - dispense_offset: Dispense Z offset (mm, default: [0.0]*n). + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDispenseParams) + else NimbusPIPDispenseParams() + ) + + n = len(ops) + + channels_involved = [0] * self.num_channels + for channel_idx in use_channels: + channels_involved[channel_idx] = 1 + + # ADC control + if params.adc_enabled: + await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + else: + await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + + # Query channel configurations + if self._channel_configurations is None: + self._channel_configurations = {} + for channel_idx in use_channels: + channel_num = channel_idx + 1 + try: + config = await self.driver.send_command( + GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + ) + assert config is not None + enabled = config["enabled"][0] if config["enabled"] else False + if channel_num not in self._channel_configurations: + self._channel_configurations[channel_num] = {} + self._channel_configurations[channel_num][2] = enabled + except Exception as e: + logger.warning(f"Failed to get channel config for channel {channel_num}: {e}") + + # Compute XY positions + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + + # Traverse height + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + deck = self.deck + + # Well bottoms + well_bottoms = [] + for op in ops: + abs_location = op.resource.get_location_wrt(deck) + op.offset + if isinstance(op.resource, Container): + abs_location.z += op.resource.material_z_thickness + hamilton_coord = deck.to_hamilton_coordinate(abs_location) + well_bottoms.append(hamilton_coord.z) + + liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + + lld_search_height = params.lld_search_height + if lld_search_height is None: + lld_search_height = [op.resource.get_absolute_size_z() for op in ops] + + minimum_heights_mm = well_bottoms.copy() + + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + for op in ops + ] + blow_out_air_volumes = [ + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops + ] + + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ + op.mix.flow_rate + if op.mix is not None + else ( + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + ) + for op in ops + ] + + # Advanced parameters + lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) + immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) + surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) + gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) + mix_position_from_liquid_surface = _fill_in_defaults( + params.mix_position_from_liquid_surface, [0.0] * n + ) + limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) + cut_off_speed = _fill_in_defaults(params.cut_off_speed, [25.0] * n) + stop_back_volume = _fill_in_defaults(params.stop_back_volume, [0.0] * n) + dispense_offset = _fill_in_defaults(params.dispense_offset, [0.0] * n) + + # Unit conversions + dispense_volumes = [round(vol * 10) for vol in volumes] + blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] + dispense_speeds = [round(fr * 10) for fr in flow_rates] + lld_search_height_units = [round(h * 100) for h in lld_search_height] + liquid_height_units = [round(h * 100) for h in liquid_heights_mm] + immersion_depth_units = [round(d * 100) for d in immersion_depth] + surface_following_distance_units = [round(d * 100) for d in surface_following_distance] + minimum_height_units = [round(z * 100) for z in minimum_heights_mm] + settling_time_units = [round(t * 10) for t in settling_time] + transport_air_volume_units = [round(v * 10) for v in transport_air_volume] + swap_speed_units = [round(s * 10) for s in swap_speed] + mix_volume_units = [round(v * 10) for v in mix_volume] + mix_speed_units = [round(s * 10) for s in mix_speed] + mix_position_from_liquid_surface_units = [ + round(p * 100) for p in mix_position_from_liquid_surface + ] + cut_off_speed_units = [round(s * 10) for s in cut_off_speed] + stop_back_volume_units = [round(v * 10) for v in stop_back_volume] + dispense_offset_units = [round(o * 100) for o in dispense_offset] + side_touch_off_distance_units = round(params.side_touch_off_distance * 100) + + # Build full-channel arrays + dispense_volumes_full = self._fill_by_channels(dispense_volumes, use_channels, default=0) + blow_out_air_volumes_full = self._fill_by_channels( + blow_out_air_volumes_units, use_channels, default=0 + ) + dispense_speeds_full = self._fill_by_channels(dispense_speeds, use_channels, default=0) + lld_search_height_full = self._fill_by_channels( + lld_search_height_units, use_channels, default=0 + ) + liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) + immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) + surface_following_distance_full = self._fill_by_channels( + surface_following_distance_units, use_channels, default=0 + ) + minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) + settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) + transport_air_volume_full = self._fill_by_channels( + transport_air_volume_units, use_channels, default=0 + ) + swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) + mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) + mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) + mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) + mix_position_from_liquid_surface_full = self._fill_by_channels( + mix_position_from_liquid_surface_units, use_channels, default=0 + ) + gamma_lld_sensitivity_full = self._fill_by_channels( + gamma_lld_sensitivity, use_channels, default=0 + ) + limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) + lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) + cut_off_speed_full = self._fill_by_channels(cut_off_speed_units, use_channels, default=0) + stop_back_volume_full = self._fill_by_channels(stop_back_volume_units, use_channels, default=0) + dispense_offset_full = self._fill_by_channels(dispense_offset_units, use_channels, default=0) + + # Default values + dispense_type = [0] * self.num_channels + min_z_endpos = traverse_height_units + mix_surface_following_distance = [0] * self.num_channels + tube_section_height = [0] * self.num_channels + tube_section_ratio = [0] * self.num_channels + recording_mode = 0 + + command = DispenseCommand( + dest=self.pipette_address, + dispense_type=dispense_type, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + lld_search_height=lld_search_height_full, + liquid_height=liquid_height_full, + immersion_depth=immersion_depth_full, + surface_following_distance=surface_following_distance_full, + minimum_height=minimum_height_full, + min_z_endpos=min_z_endpos, + swap_speed=swap_speed_full, + transport_air_volume=transport_air_volume_full, + dispense_volume=dispense_volumes_full, + stop_back_volume=stop_back_volume_full, + blow_out_air_volume=blow_out_air_volumes_full, + dispense_speed=dispense_speeds_full, + cut_off_speed=cut_off_speed_full, + settling_time=settling_time_full, + mix_volume=mix_volume_full, + mix_cycles=mix_cycles_full, + mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, + mix_surface_following_distance=mix_surface_following_distance, + mix_speed=mix_speed_full, + side_touch_off_distance=side_touch_off_distance_units, + dispense_offset=dispense_offset_full, + tube_section_height=tube_section_height, + tube_section_ratio=tube_section_ratio, + lld_mode=lld_mode_full, + gamma_lld_sensitivity=gamma_lld_sensitivity_full, + tadm_enabled=params.tadm_enabled, + limit_curve_index=limit_curve_index_full, + recording_mode=recording_mode, + ) + + await self.driver.send_command(command) + logger.info(f"Dispensed on channels {use_channels}") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/star/__init__.py b/pylabrobot/hamilton/liquid_handlers/star/__init__.py new file mode 100644 index 00000000000..d29a8f025eb --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/__init__.py @@ -0,0 +1,5 @@ +from .autoload import STARAutoload +from .cover import STARCover +from .star import STAR, STARLet +from .wash_station import STARWashStation +from .x_arm import STARXArm diff --git a/pylabrobot/hamilton/liquid_handlers/star/autoload.py b/pylabrobot/hamilton/liquid_handlers/star/autoload.py new file mode 100644 index 00000000000..e370d2cb18e --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/autoload.py @@ -0,0 +1,666 @@ +"""STARAutoload: autoload module control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple + +from pylabrobot.resources.barcode import Barcode, Barcode1DSymbology + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARAutoload: + """Controls the autoload module on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for the autoload subsystem and delegates I/O to the driver. + + Methods that the legacy backend called with ``Carrier`` objects now take + ``carrier_end_rail: int`` — the caller is responsible for computing the rail + from carrier geometry. + """ + + # 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 + "Code 128 (Subset B and C)": "02", # bit 1 + "Code 39": "04", # bit 2 + "Codebar": "08", # bit 3 + "Code 2of5 Interleaved": "10", # bit 4 + "UPC A/E": "20", # bit 5 + "YESN/EAN 8": "40", # bit 6 + "ANY 1D": "7F", # bits 0-6 + } + + def __init__(self, driver: "STARDriver", instrument_size_slots: int = 54): + self.driver = driver + self._instrument_size_slots = instrument_size_slots + self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" + + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self, backend_params=None): + """Initialize autoload module if not already initialized, then park.""" + already_initialized = await self.request_initialization_status() + if not already_initialized: + await self.driver.send_command(module="C0", command="II") + await self.park() + + async def _on_stop(self): + pass + + async def request_initialization_status(self) -> bool: + """Request autoload initialization status (I0:QW).""" + resp = await self.driver.send_command(module="I0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + # -- z-position safety ----------------------------------------------------- + + async def move_to_safe_z_position(self): + """Move autoload carrier handling wheel to safe Z position (C0:IV).""" + return await self.driver.send_command(module="C0", command="IV") + + # -- position queries ------------------------------------------------------ + + async def request_track(self) -> int: + """Request current track of the autoload carrier handler (C0:QA). + + Returns: + track (0..54) + """ + resp = await self.driver.send_command(module="C0", command="QA", fmt="qa##") + return int(resp["qa"]) + + async def request_type(self) -> str: + """Query the autoload module type (C0:CQ). + + Returns: + Human-readable autoload module type string, or the raw code if unknown. + """ + + autoload_type_dict = { + 0: "ML-STAR with 1D Barcode Scanner", + 1: "XRP Lite", + 2: "ML-STAR with 2D Barcode Scanner", + } + + resp = await self.driver.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) + + # -- carrier sensing ------------------------------------------------------- + + @staticmethod + def _decode_hex_bitmask_to_track_list(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: List[int] = [] + bit_index = 1 + + 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 (C0:RC). + + Returns: + Sorted list of deck rail positions where carriers are present. + """ + resp = await self.driver.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]: + """Scan loading tray positions for carrier presence (C0:CS). + + Returns: + Sorted list of loading-tray positions where carriers are present. + """ + resp = await self.driver.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 (C0:CT). + + Args: + track: The loading-tray track number to query (1-54). + + Returns: + True if a carrier is detected at the given track; False otherwise. + """ + + if not (1 <= track <= 54): + raise ValueError("track must be between 1 and 54") + + track_str = str(track).zfill(2) + + resp = await self.driver.send_command( + module="C0", + command="CT", + fmt="ct#", + cp=track_str, + ) + if resp is None: + raise RuntimeError("Expected a response from send_command for CT, got None") + + return int(resp["ct"]) == 1 + + # -- movement commands ----------------------------------------------------- + + async def move_to_track(self, track: int): + """Move autoload to specific track position (I0:XP).""" + + if not (1 <= track <= 54): + raise ValueError("track must be between 1 and 54") + + await self.move_to_safe_z_position() + + track_no_as_safe_str = str(track).zfill(2) + return await self.driver.send_command(module="I0", command="XP", xp=track_no_as_safe_str) + + async def park(self): + """Park autoload to max position (I0:XP).""" + + max_x_pos = str(self._instrument_size_slots).zfill(2) + + await self.move_to_safe_z_position() + + return await self.driver.send_command(module="I0", command="XP", xp=max_x_pos) + + # -- belt operations ------------------------------------------------------- + + async def take_carrier_out_to_belt(self, carrier_end_rail: int): + """Take carrier out to identification position for barcode reading (C0:CN). + + Args: + carrier_end_rail: End rail position of the carrier on the deck. + """ + + carrier_on_loading_tray = await self.request_presence_of_single_carrier_on_loading_tray( + carrier_end_rail + ) + + if not carrier_on_loading_tray: + try: + await self.driver.send_command( + module="C0", + command="CN", + cp=str(carrier_end_rail).zfill(2), + ) + except Exception as e: + await self.move_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}.") + + async def unload_carrier_after_barcode_scanning(self): + """Unload carrier back to loading tray after barcode scanning (C0:CA).""" + try: + resp = await self.driver.send_command( + module="C0", + command="CA", + ) + except Exception as e: + await self.move_to_safe_z_position() + raise RuntimeError(f"Failed to unload carrier after barcode scanning: {e}") + + return resp + + async def load_carrier_from_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]]: + """Finish loading a carrier currently on the autoload sled (C0:CL). + + Optionally reads container barcodes during the load. + """ + + if barcode_reading_direction not in ["horizontal", "vertical"]: + raise ValueError( + f"barcode_reading_direction must be 'horizontal' or 'vertical', " + f"got {barcode_reading_direction!r}" + ) + if not (0 <= reading_position_of_first_barcode <= 470): + raise ValueError( + f"reading_position_of_first_barcode must be between 0 and 470, " + f"got {reading_position_of_first_barcode}" + ) + if not (0 <= no_container_per_carrier <= 32): + raise ValueError( + f"no_container_per_carrier must be between 0 and 32, got {no_container_per_carrier}" + ) + if not (0 <= distance_between_containers <= 470): + raise ValueError( + f"distance_between_containers must be between 0 and 470, got {distance_between_containers}" + ) + if not (0.1 <= width_of_reading_window <= 99.9): + raise ValueError( + f"width_of_reading_window must be between 0.1 and 99.9, got {width_of_reading_window}" + ) + if not (1.5 <= reading_speed <= 160.0): + raise ValueError(f"reading_speed must be between 1.5 and 160.0, got {reading_speed}") + + barcode_reading_direction_dict = { + "vertical": "0", + "horizontal": "1", + } + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + if barcode_symbology is None: + raise RuntimeError("barcode_symbology is None after fallback to default") + + 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.driver.send_command( + module="C0", + command="CL", + bd=barcode_reading_direction_dict[barcode_reading_direction], + bp=reading_position_of_first_barcode_str, + cn=no_container_per_carrier_str, + co=distance_between_containers_str, + cf=width_of_reading_window_str, + cv=reading_speed_str, + ) + except Exception as e: + await self.move_to_safe_z_position() + raise RuntimeError(f"Failed to load carrier from autoload belt: {e}") + + if park_autoload_after: + await self.park() + + if not isinstance(resp, str): + raise RuntimeError(f"Expected a string response from CL command, got {resp!r}") + + barcode_dict: Dict[int, Optional[Barcode]] = {} + + if barcode_reading: + resp_list = resp.split("bb/")[-1].split("/") # remove header + + if len(resp_list) != no_container_per_carrier: + raise ValueError( + 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 + + # -- barcode commands ------------------------------------------------------ + + async def set_1d_barcode_type( + self, + barcode_symbology: Optional[Barcode1DSymbology], + ) -> None: + """Set 1D barcode type for autoload barcode reading (C0:CB).""" + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + if barcode_symbology is None: + raise RuntimeError("barcode_symbology is None after fallback to default") + + await self.driver.send_command( + module="C0", + command="CB", + bt=self.barcode_1d_symbology_dict[barcode_symbology], + ) + + self._default_1d_symbology = barcode_symbology + + async def load_carrier_from_tray_and_scan_carrier_barcode( + self, + carrier_end_rail: int, + 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 (C0:CI). + + Args: + carrier_end_rail: End rail position of the carrier. + """ + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + if barcode_symbology is None: + raise RuntimeError("barcode_symbology is None after fallback to default") + + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + if not (1 <= int(carrier_end_rail_str) <= 54): + raise ValueError(f"carrier_end_rail must be between 1 and 54, got {carrier_end_rail}") + if not (0 <= barcode_position <= 470): + raise ValueError(f"barcode_position must be between 0 and 470, got {barcode_position}") + if not (0.1 <= barcode_reading_window_width <= 99.9): + raise ValueError( + f"barcode_reading_window_width must be between 0.1 and 99.9, " + f"got {barcode_reading_window_width}" + ) + if not (1.5 <= reading_speed <= 160.0): + raise ValueError(f"reading_speed must be between 1.5 and 160.0, got {reading_speed}") + + try: + resp = await self.driver.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_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") + + # -- high-level load / unload ---------------------------------------------- + + async def load_carrier( + self, + carrier_end_rail: int, + 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_end_rail: End rail position of the carrier (1-54). + carrier_barcode_reading: Whether to read the carrier barcode. Default True. + barcode_reading: Whether to read container barcodes. Default False. + 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 + + if not (1 <= carrier_end_rail <= 54): + raise ValueError("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?""" + ) + + # Scan carrier barcode + carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail, carrier_barcode_reading=carrier_barcode_reading + ) + + # Load carrier + if barcode_reading: + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + self._default_1d_symbology = barcode_symbology + + resp = await self.load_carrier_from_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: + resp = await self.load_carrier_from_belt(barcode_reading=False, park_autoload_after=False) + + if park_autoload_after: + await self.park() + + output = { + "carrier_barcode": carrier_barcode if carrier_barcode_reading else None, + "container_barcodes": resp if barcode_reading else None, + } + + return output + + async def unload_carrier( + self, + carrier_end_rail: int, + park_autoload_after: bool = True, + ): + """Use autoload to unload carrier (C0:CR). + + Args: + carrier_end_rail: End rail position of the carrier (1-54). + """ + + if not (1 <= carrier_end_rail <= 54): + raise ValueError("carrier loading rail must be between 1 and 54") + + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + resp = await self.driver.send_command( + module="C0", + command="CR", + cp=carrier_end_rail_str, + ) + + if park_autoload_after: + await self.park() + + return resp + + # -- LED / monitoring ------------------------------------------------------ + + async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): + """Set loading indicators (LEDs) (C0:CP). + + Args: + bit_pattern: On if True, off otherwise. Length 54. + blink_pattern: Blinking if True, steady otherwise. Length 54. + """ + + if len(bit_pattern) != 54: + raise ValueError(f"bit_pattern must be length 54, got {len(bit_pattern)}") + if len(blink_pattern) != 54: + raise ValueError(f"blink_pattern must be length 54, got {len(blink_pattern)}") + + 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.driver.send_command( + module="C0", + command="CP", + cl=bit_pattern_hex, + cb=blink_pattern_hex, + ) + + async def set_carrier_monitoring(self, should_monitor: bool = False): + """Set carrier monitoring (C0:CU). + + Args: + should_monitor: whether carrier should be monitored. + """ + + return await self.driver.send_command(module="C0", command="CU", cu=should_monitor) + + async def verify_and_wait_for_carriers( + self, + carrier_rails: List[Tuple[int, int]], + check_interval: float = 1.0, + ): + """Verify that carriers have been loaded at expected rail positions. + + 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: + carrier_rails: List of (start_rail, end_rail) tuples for expected carriers. + check_interval: Interval in seconds between presence checks (default: 1.0) + + Raises: + ValueError: If carrier_rails is empty. + """ + + if len(carrier_rails) == 0: + raise ValueError("No carriers found on deck. Assign carriers to the deck.") + + # 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}") + 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: + bit_pattern = [False] * 54 + blink_pattern = [False] * 54 + + for missing_end_rail in missing_end_rails: + for start_rail, end_rail in carrier_rails: + if end_rail == missing_end_rail: + for rail in range(start_rail, end_rail + 1): + if 1 <= rail <= 54: + indicator_index = rail - 1 + bit_pattern[indicator_index] = True + blink_pattern[indicator_index] = True + break + + await self.set_loading_indicators(bit_pattern[::-1], blink_pattern[::-1]) + + await asyncio.sleep(check_interval) + + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + 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") diff --git a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py new file mode 100644 index 00000000000..e7f9e72043d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py @@ -0,0 +1,150 @@ +"""STARChatterboxDriver: logs commands instead of sending them over USB.""" + +import logging + +from .autoload import STARAutoload +from .cover import STARCover +from pylabrobot.resources.hamilton import HamiltonDeck + +from .driver import ( + DriveConfiguration, + ExtendedConfiguration, + MachineConfiguration, + STARDriver, +) +from .head96_backend import STARHead96Backend +from .iswap import iSWAPBackend +from .pip_backend import STARPIPBackend +from .wash_station import STARWashStation +from .x_arm import STARXArm + +_DEFAULT_MACHINE_CONF = MachineConfiguration( + pip_type_1000ul=True, + kb_iswap_installed=True, + auto_load_installed=True, + num_pip_channels=8, +) + +_DEFAULT_EXTENDED_CONF = 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, +) + + +logger = logging.getLogger(__name__) + + +class STARChatterboxDriver(STARDriver): + """Chatterbox driver for STAR. Logs firmware commands instead of sending them over USB.""" + + def __init__( + self, + deck: HamiltonDeck, + num_channels: int = 8, + machine_configuration: MachineConfiguration = _DEFAULT_MACHINE_CONF, + extended_configuration: ExtendedConfiguration = _DEFAULT_EXTENDED_CONF, + ): + super().__init__(deck=deck) + self._num_channels = num_channels + self._machine_configuration = machine_configuration + self._extended_configuration = extended_configuration + + @property + def num_channels(self) -> int: + return self._num_channels + + # -- lifecycle: skip USB, use canned config -------------------------------- + + async def setup(self, backend_params=None): + # No USB — just set config and create backends. + self.id_ = 0 + self.machine_conf = self._machine_configuration + self.extended_conf = self._extended_configuration + + self.pip = STARPIPBackend(self) + + self._channels_minimum_y_spacing = [9.0] * self._num_channels + + if self.extended_conf.left_x_drive.core_96_head_installed: + self.head96 = STARHead96Backend(self, deck=self.deck) + else: + self.head96 = None + + if self.extended_conf.left_x_drive.iswap_installed: + self.iswap = iSWAPBackend(driver=self) + self.iswap._version = "chatterbox" + self.iswap._parked = True + else: + self.iswap = None + + if self.machine_conf.auto_load_installed: + self.autoload = STARAutoload( + driver=self, + instrument_size_slots=self.extended_conf.instrument_size_slots, + ) + else: + self.autoload = None + + self.left_x_arm = STARXArm(driver=self, side="left") + if self.extended_conf.right_x_drive_large: + self.right_x_arm = STARXArm(driver=self, side="right") + else: + self.right_x_arm = None + + self.cover = STARCover(driver=self) + + if self.machine_conf.wash_station_1_installed or self.machine_conf.wash_station_2_installed: + self.wash_station = STARWashStation(driver=self) + else: + self.wash_station = None + + for sub in self._subsystems: + await sub._on_setup() + + async def stop(self): + for sub in reversed(self._subsystems): + await sub._on_stop() + self.machine_conf = None + self.extended_conf = None + self._channels_minimum_y_spacing = [] + self.head96 = None + self.iswap = None + self.autoload = None + self.left_x_arm = None + self.right_x_arm = None + self.cover = None + self.wash_station = None + + # -- I/O: print instead of USB -------------------------------------------- + + async def send_command( + self, + module, + command, + auto_id=True, + tip_pattern=None, + write_timeout=None, + read_timeout=None, + wait=True, + fmt=None, + **kwargs, + ): + cmd, _ = self._assemble_command( + module=module, + command=command, + auto_id=auto_id, + tip_pattern=tip_pattern, + **kwargs, + ) + logger.debug("chatterbox cmd: %s", cmd) + return None + + async def send_raw_command(self, command, write_timeout=None, read_timeout=None, wait=True): + logger.debug("chatterbox raw: %s", command) + return None diff --git a/pylabrobot/hamilton/liquid_handlers/star/core.py b/pylabrobot/hamilton/liquid_handlers/star/core.py new file mode 100644 index 00000000000..ab119589664 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/core.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from pylabrobot.capabilities.arms.backend import GripperArmBackend +from pylabrobot.capabilities.arms.standard import CartesianPose +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + + +class CoreGripper(GripperArmBackend): + """Backend for Hamilton CoRe gripper tools. + + The CoRe gripper uses two pipetting channels to grip plates along the Y axis. + Tool management (pick up / return) is handled by the STAR backend. + """ + + def __init__(self, driver: STARDriver): + self.driver = driver + + # -- lifecycle -------------------------------------------------------------- + + async def request_gripper_location(self, backend_params=None) -> CartesianPose: + raise NotImplementedError("CoreGripper does not support request_gripper_location") + + # -- ArmBackend interface --------------------------------------------------- + + @dataclass + class PickUpParams(BackendParams): + """CoRe gripper parameters for plate pickup. + + Args: + grip_strength: Grip strength (0 = low, 99 = high). Must be between 0 and 99. + Default 15. + y_gripping_speed: Y-axis gripping speed in mm/s. Default 5.0. + z_speed: Z-axis speed in mm/s. Default 50.0. + minimum_traverse_height: Minimum Z clearance in mm before lateral movement. + Must be between 0 and 360.0. Default 280.0. + z_position_at_end: Z position in mm at the end of the command. Must be between + 0 and 360.0. Default 280.0. + """ + + grip_strength: int = 15 + y_gripping_speed: float = 5.0 + z_speed: float = 50.0 + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center position [mm]. + resource_width: Plate width in Y direction [mm]. + backend_params: CoreGripper.PickUpParams for firmware-specific settings. + """ + if not isinstance(backend_params, CoreGripper.PickUpParams): + backend_params = CoreGripper.PickUpParams() + + open_gripper_position = resource_width + 3.0 + plate_width = resource_width - 3.0 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.grip_strength <= 99: + raise ValueError("grip_strength must be between 0 and 99") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + + await self.driver.send_command( + module="C0", + command="ZP", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yv=f"{round(backend_params.y_gripping_speed * 10):04}", + zj=f"{abs(round(location.z * 10)):04}", + zy=f"{round(backend_params.z_speed * 10):04}", + yo=f"{round(open_gripper_position * 10):04}", + yg=f"{round(plate_width * 10):04}", + yw=f"{backend_params.grip_strength:02}", + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + ) + + @dataclass + class DropParams(BackendParams): + """CoRe gripper parameters for plate drop. + + Args: + z_press_on_distance: Distance in mm to press down on the plate after placing it. + Default 0.0. + z_speed: Z-axis speed in mm/s. Default 50.0. + minimum_traverse_height: Minimum Z clearance in mm before lateral movement. + Must be between 0 and 360.0. Default 280.0. + z_position_at_end: Z position in mm at the end of the command. Must be between + 0 and 360.0. Default 280.0. + """ + + z_press_on_distance: float = 0.0 + z_speed: float = 50.0 + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center position [mm]. + resource_width: Plate width [mm]. Used to compute open gripper position. + backend_params: CoreGripper.DropParams for firmware-specific settings. + """ + if not isinstance(backend_params, CoreGripper.DropParams): + backend_params = CoreGripper.DropParams() + + open_gripper_position = resource_width + 3.0 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + + await self.driver.send_command( + module="C0", + command="ZR", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + zj=f"{abs(round(location.z * 10)):04}", + zi=f"{round(backend_params.z_press_on_distance * 10):03}", + zy=f"{round(backend_params.z_speed * 10):04}", + yo=f"{round(open_gripper_position * 10):04}", + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + ) + + @dataclass + class MoveToLocationParams(BackendParams): + """CoRe gripper parameters for moving a held plate to a new position. + + Args: + acceleration_index: Acceleration index for movement. Must be between 0 and 4. + Default 4. + z_speed: Z-axis speed in mm/s. Default 50.0. + minimum_traverse_height: Minimum Z clearance in mm before lateral movement. + Must be between 0 and 360.0. Default 280.0. + """ + + acceleration_index: int = 4 + z_speed: float = 50.0 + minimum_traverse_height: float = 280.0 + + async def move_to_location( + self, + location: Coordinate, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position [mm]. + backend_params: CoreGripper.MoveToLocationParams for firmware-specific settings. + """ + if not isinstance(backend_params, CoreGripper.MoveToLocationParams): + backend_params = CoreGripper.MoveToLocationParams() + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + + await self.driver.send_command( + module="C0", + command="ZM", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + xg=backend_params.acceleration_index, + yj=f"{abs(round(location.y * 10)):04}", + zj=f"{abs(round(location.z * 10)):04}", + zy=f"{round(backend_params.z_speed * 10):04}", + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + ) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the CoRe gripper.""" + await self.driver.send_command(module="C0", command="ZO") + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + raise NotImplementedError( + "CoreGripper does not support close_gripper directly. Use pick_up_at_location instead." + ) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + raise NotImplementedError("CoreGripper does not support is_gripper_closed") + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError("CoreGripper does not support halt") + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError( + "CoreGripper does not support park. Tool management is handled by the STAR backend." + ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/cover.py b/pylabrobot/hamilton/liquid_handlers/star/cover.py new file mode 100644 index 00000000000..5803490a936 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/cover.py @@ -0,0 +1,77 @@ +"""STARCover: cover and port control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARCover: + """Controls the cover and port outputs on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for the cover control subsystem and delegates I/O to the driver. + """ + + def __init__(self, driver: "STARDriver"): + self.driver = driver + + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self, backend_params=None): + pass + + async def _on_stop(self): + pass + + # -- commands -------------------------------------------------------------- + + async def lock(self): + """Lock cover (C0:CO).""" + return await self.driver.send_command(module="C0", command="CO") + + async def unlock(self): + """Unlock cover (C0:HO).""" + return await self.driver.send_command(module="C0", command="HO") + + async def disable(self): + """Disable cover control (C0:CD).""" + return await self.driver.send_command(module="C0", command="CD") + + async def enable(self): + """Enable cover control (C0:CE).""" + return await self.driver.send_command(module="C0", command="CE") + + async def set_output(self, output: int = 1): + """Set cover output (C0:OS). + + Args: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + if not 1 <= output <= 3: + raise ValueError("output must be between 1 and 3") + return await self.driver.send_command(module="C0", command="OS", on=output) + + async def reset_output(self, output: int = 1): + """Reset output (C0:QS). + + Args: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + if not 1 <= output <= 3: + raise ValueError("output must be between 1 and 3") + return await self.driver.send_command(module="C0", command="QS", on=output, fmt="#") + + async def is_open(self) -> bool: + """Request whether the cover is open (C0:QC). + + Returns: + True if the cover is open. + """ + resp = await self.driver.send_command(module="C0", command="QC", fmt="qc#") + return bool(resp["qc"]) diff --git a/pylabrobot/hamilton/liquid_handlers/star/driver.py b/pylabrobot/hamilton/liquid_handlers/star/driver.py new file mode 100644 index 00000000000..1198f0aa066 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/driver.py @@ -0,0 +1,1145 @@ +"""STARDriver: inherits HamiltonLiquidHandler, adds STAR-specific config and error handling.""" + +import asyncio +import datetime +import enum +import logging +import math +import re +from dataclasses import dataclass, field +from typing import Any, List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler +from pylabrobot.resources.hamilton import HamiltonDeck, TipPickupMethod, TipSize + +from .autoload import STARAutoload +from .cover import STARCover +from .errors import ( + star_firmware_string_to_error, +) +from .fw_parsing import parse_star_firmware_version_date, parse_star_fw_string +from .head96_backend import STARHead96Backend +from .iswap import iSWAPBackend +from .pip_backend import STARPIPBackend +from .wash_station import STARWashStation +from .x_arm import STARXArm + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Configuration dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class DriveConfiguration: + """Configuration for an X drive (left or right).""" + + 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 + + +@dataclass +class MachineConfiguration: + """Response from RM (Request Machine Configuration) command.""" + + pip_type_1000ul: bool = False + kb_iswap_installed: bool = False + main_front_cover_monitoring_installed: bool = False + auto_load_installed: bool = False + wash_station_1_installed: bool = False + wash_station_2_installed: bool = False + temp_controlled_carrier_1_installed: bool = False + temp_controlled_carrier_2_installed: bool = False + num_pip_channels: int = 0 + + +@dataclass +class ExtendedConfiguration: + """Response from QM (Request Extended Configuration) command.""" + + left_x_drive_large: bool = False + ka_core_96_head_installed: bool = False + right_x_drive_large: bool = False + pump_station_1_installed: bool = False + pump_station_2_installed: bool = False + wash_station_1_type_cr: bool = False + wash_station_2_type_cr: bool = False + left_cover_installed: bool = False + right_cover_installed: bool = False + additional_front_cover_monitoring_installed: bool = False + pump_station_3_installed: bool = False + multi_channel_nano_pipettor_installed: bool = False + dispensing_head_384_installed: bool = False + xl_channels_installed: bool = False + tube_gripper_installed: bool = False + waste_direction_left: bool = False + iswap_gripper_wide: bool = False + additional_channel_nano_pipettor_installed: bool = False + imaging_channel_installed: bool = False + robotic_channel_installed: bool = False + channel_order_ox_first: bool = False + x0_interface_ham_can: bool = False + park_heads_with_iswap_off: bool = False + configuration_data_3: int = 0 + instrument_size_slots: int = 54 + auto_load_size_slots: int = 54 + tip_waste_x_position: float = 1340.0 + left_x_drive: DriveConfiguration = field(default_factory=DriveConfiguration) + right_x_drive: DriveConfiguration = field(default_factory=DriveConfiguration) + min_iswap_collision_free_position: float = 350.0 + max_iswap_collision_free_position: float = 1140.0 + left_x_arm_width: float = 370.0 + right_x_arm_width: float = 370.0 + num_xl_channels: int = 0 + num_robotic_channels: int = 0 + min_raster_pitch_pip_channels: float = 9.0 + min_raster_pitch_xl_channels: float = 36.0 + min_raster_pitch_robotic_channels: float = 36.0 + pip_maximal_y_position: float = 606.5 + left_arm_min_y_position: float = 6.0 + right_arm_min_y_position: float = 6.0 + + +# --------------------------------------------------------------------------- +# STARDriver +# --------------------------------------------------------------------------- + + +class STARDriver(HamiltonLiquidHandler): + """Driver for Hamilton STAR liquid handlers. + + Inherits USB I/O, command assembly, and background reading from HamiltonLiquidHandler. + Adds STAR-specific firmware parsing, error handling, and machine configuration. + """ + + PIP_X_MIN_WITH_LEFT_SIDE_PANEL: float = 320.0 + HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL: float = 0.0 + + def __init__( + self, + deck: HamiltonDeck, + 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, + ): + super().__init__( + id_product=0x8000, + device_address=device_address, + serial_number=serial_number, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + self.deck = deck + self.left_side_panel_installed = left_side_panel_installed + + # Populated during setup(). + self.machine_conf: Optional[MachineConfiguration] = None + self.extended_conf: Optional[ExtendedConfiguration] = None + self._channels_minimum_y_spacing: List[float] = [] + self.pip: STARPIPBackend # set in setup() + self.head96: Optional[STARHead96Backend] = None # set in setup() if installed + self.iswap: Optional["iSWAPBackend"] = None # set in setup() if installed + self.autoload: Optional["STARAutoload"] = None # set in setup() if installed + self.left_x_arm: Optional["STARXArm"] = None # set in setup() + self.right_x_arm: Optional["STARXArm"] = None # set in setup() + self.cover: Optional["STARCover"] = None # set in setup() + self.wash_station: Optional["STARWashStation"] = None # set in setup() + + # -- HamiltonLiquidHandler abstract methods -------------------------------- + + @property + def module_id_length(self) -> int: + return 2 + + @property + def num_channels(self) -> int: + if self.machine_conf is None: + raise RuntimeError("Driver not set up — call setup() first.") + return self.machine_conf.num_pip_channels + + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + 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) -> None: + module = resp[:2] + if module == "C0": + exp = r"er(?P[0-9]{2}/[0-9]{2})" + for mod 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" ?(?:{mod}(?P<{mod}>[0-9]{{2}}/[0-9]{{2}}))?" + errors = re.search(exp, resp) + else: + exp = f"er(?P<{module}>[0-9]{{2}})" + errors = re.search(exp, resp) + + if errors is None: + return + + errors_dict = {k: v for k, v in errors.groupdict().items() if v is not None} + errors_dict = {k: v for k, v in errors_dict.items() if v not in ("00", "00/00")} + + if len(errors_dict) > 0: + raise star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) + + async def ensure_iswap_parked(self) -> None: + """Park the iSWAP if it is installed and not already parked.""" + if self.iswap is not None and not self.iswap.parked: + await self.iswap.park() + + def _parse_response(self, resp: str, fmt: Any) -> dict: + return parse_star_fw_string(resp, fmt) + + 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, + ) -> None: + if not 0 <= tip_type_table_index <= 99: + raise ValueError("tip_type_table_index must be between 0 and 99") + if not 1 <= tip_length <= 1999: + raise ValueError("tip_length must be between 1 and 1999") + if not 1 <= maximum_tip_volume <= 56000: + raise ValueError("maximum_tip_volume must be between 1 and 56000") + + 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, + ) + + # -- lifecycle ------------------------------------------------------------ + + async def setup(self, backend_params: Optional[BackendParams] = None): + assert self.deck is not None, "STARDriver requires a deck before setup()" + await super().setup(backend_params=backend_params) + self.id_ = 0 + self.machine_conf = await self._request_machine_configuration() + self.extended_conf = await self._request_extended_configuration() + + # Instrument-level initialization. + initialized = await self.request_instrument_initialization_status() + if not initialized: + logger.info("Running instrument pre-initialization (C0:VI).") + await self.pre_initialize_instrument() + + # Create backends based on discovered config. + self.pip = STARPIPBackend(self) + + self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() + + if self.extended_conf.left_x_drive.core_96_head_installed: + self.head96 = STARHead96Backend(self, deck=self.deck) + else: + self.head96 = None + + if self.extended_conf.left_x_drive.iswap_installed: + self.iswap = iSWAPBackend(driver=self) + else: + self.iswap = None + + if self.machine_conf.auto_load_installed: + self.autoload = STARAutoload( + driver=self, + instrument_size_slots=self.extended_conf.instrument_size_slots, + ) + else: + self.autoload = None + + self.left_x_arm = STARXArm(driver=self, side="left") + if self.extended_conf.right_x_drive_large: + self.right_x_arm = STARXArm(driver=self, side="right") + else: + self.right_x_arm = None + + self.cover = STARCover(driver=self) + + if self.machine_conf.wash_station_1_installed or self.machine_conf.wash_station_2_installed: + self.wash_station = STARWashStation(driver=self) + else: + self.wash_station = None + + # Initialize subsystems. + for sub in self._subsystems: + await sub._on_setup() + + @property + def _subsystems(self) -> List[Any]: + """Subsystems whose lifecycle is managed by the driver directly. + + Note: PIP, head96, iSWAP, and autoload are excluded — their lifecycle + is managed by the higher-level STAR device, which controls parallelization + and passes context (deck) they need. + """ + subs: List[Any] = [self.cover] + if self.left_x_arm is not None: + subs.append(self.left_x_arm) + if self.right_x_arm is not None: + subs.append(self.right_x_arm) + if self.wash_station is not None: + subs.append(self.wash_station) + return subs + + async def stop(self): + for sub in reversed(self._subsystems): + await sub._on_stop() + await super().stop() + self.machine_conf = None + self.extended_conf = None + self._channels_minimum_y_spacing = [] + self.head96 = None + self.iswap = None + self.autoload = None + self.left_x_arm = None + self.right_x_arm = None + self.cover = None + self.wash_station = None + + # -- liquid level probing --------------------------------------------------- + + async def probe_liquid_heights( + self, containers, use_channels, resource_offsets=None, move_to_z_safety_after=True, **kwargs + ): + """Probe liquid heights using cLLD. Override in subclasses with real implementation.""" + raise NotImplementedError( + "probe_liquid_heights is not implemented on STARDriver. " + "Use STARBackend (legacy) or implement probing on your driver subclass." + ) + + # -- core gripper tool management ------------------------------------------ + + async def pick_up_core_gripper_tools( + self, + x_position: float, + back_channel_y: float, + front_channel_y: float, + back_channel: int, + front_channel: int, + begin_z: float = 235.0, + end_z: float = 225.0, + traversal_height: float = 280.0, + ): + """Pick up CoRe gripper tools from the mount (C0ZT).""" + await self.send_command( + module="C0", + command="ZT", + xs=f"{round(x_position * 10):05}", + xd="0", + ya=f"{round(back_channel_y * 10):04}", + yb=f"{round(front_channel_y * 10):04}", + pa=f"{back_channel + 1:02}", + pb=f"{front_channel + 1:02}", + tp=f"{round(begin_z * 10):04}", + tz=f"{round(end_z * 10):04}", + th=round(traversal_height * 10), + tt="14", + ) + + async def return_core_gripper_tools( + self, + x_position: float, + back_channel_y: float, + front_channel_y: float, + begin_z: float = 215.0, + end_z: float = 205.0, + traversal_height: float = 280.0, + ): + """Return CoRe gripper tools to the mount (C0ZS).""" + await self.send_command( + module="C0", + command="ZS", + xs=f"{round(x_position * 10):05}", + xd="0", + ya=f"{round(back_channel_y * 10):04}", + yb=f"{round(front_channel_y * 10):04}", + tp=f"{round(begin_z * 10):04}", + tz=f"{round(end_z * 10):04}", + th=round(traversal_height * 10), + te=round(traversal_height * 10), + ) + + # -- machine configuration ------------------------------------------------ + + async def _request_machine_configuration(self) -> MachineConfiguration: + 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: + 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####", + ) + + def _parse_drive(byte1: int, byte2: int) -> DriveConfiguration: + 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)), + ) + + 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=_parse_drive(resp["xl"], resp["xn"]), + right_x_drive=_parse_drive(resp["xr"], resp["xo"]), + 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, + ) + + # -- generic instrument operations -- + + 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 + + # --- Firmware queries --- + + async def request_error_code(self): + """Request error code (C0:RE). + + Retrieves the last saved error messages. The error buffer is automatically voided + when a new command is started. All configured nodes are displayed. + """ + + return await self.send_command(module="C0", command="RE") + + async def request_firmware_version(self) -> datetime.date: + """Request firmware version (C0:RF).""" + + resp = await self.send_command(module="C0", command="RF") + return parse_star_firmware_version_date(str(resp)) + + async def request_parameter_value(self): + """Request parameter value (C0:RA).""" + + return await self.send_command(module="C0", command="RA") + + async def request_master_status(self): + """Request master status (C0:RQ).""" + + return await self.send_command(module="C0", command="RQ") + + async def request_eeprom_data_correctness(self): + """Request EEPROM data correctness (C0:QV).""" + + return await self.send_command(module="C0", command="QV") + + # --- Hardware config queries --- + + async def request_electronic_board_type(self): + """Request electronic board type (C0:QB). + + Returns: + The board type. + """ + + resp = await self.send_command(module="C0", command="QB", fmt="qb#") + try: + return STARDriver.BoardType(resp["qb"]) + except ValueError: + return STARDriver.BoardType.UNKNOWN + + async def request_supply_voltage(self): + """Request supply voltage (C0:MU). + + Request supply voltage (for LDPB only). + """ + + return await self.send_command(module="C0", command="MU") + + async def request_number_of_presence_sensors_installed(self): + """Request number of presence sensors installed (C0:SR). + + Returns: + Number of sensors installed (1...103). + """ + + resp = await self.send_command(module="C0", command="SR", fmt="sr###") + return resp["sr"] + + # --- Init status + diagnostics --- + + async def request_instrument_initialization_status(self) -> bool: + """Request instrument initialization status (C0:QW).""" + + resp = await self.send_command(module="C0", 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 (C0:VP). + + Returns: + Name of last parameter with syntax error, optionally followed by the received value, + minimal permitted value, and maximal permitted value. + """ + + return await self.send_command(module="C0", command="VP", fmt="vp&&") + + # --- Runtime control --- + + async def set_single_step_mode(self, single_step_mode: bool = False): + """Set single step mode (C0:AM). + + 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 in single step mode (C0:NS).""" + + return await self.send_command(module="C0", command="NS") + + async def halt(self): + """Halt (C0:HD). + + Intermediate sequences not yet carried out and the commands in the command stack are + discarded. The sequence already in process is completed. + """ + + return await self.send_command(module="C0", command="HD") + + async def set_not_stop(self, non_stop): + """Set not stop mode (C0:AB/AW). + + Args: + non_stop: True if non stop mode should be turned on after command is sent. + """ + + if non_stop: + return await self.send_command(module="C0", command="AB") + else: + return await self.send_command(module="C0", command="AW") + + async def save_all_cycle_counters(self): + """Save all cycle counters of the instrument (C0:AZ).""" + + return await self.send_command(module="C0", command="AZ") + + # --- X-drive queries --- + + async def request_maximal_ranges_of_x_drives(self): + """Request maximal ranges of X drives (C0:RU).""" + + return await self.send_command(module="C0", command="RU") + + async def request_present_wrap_size_of_installed_arms(self): + """Request present wrap size of installed arms (C0:UA).""" + + return await self.send_command(module="C0", command="UA") + + # -- EEPROM operations -- + + async def store_installation_data( + self, + date: Optional[datetime.datetime] = None, + serial_number: str = "0000", + ): + """Store installation data (C0:SI). + + Args: + date: installation date. Defaults to now. + serial_number: 4-character serial number string. + """ + + if date is None: + date = datetime.datetime.now() + if len(serial_number) != 4: + raise ValueError("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: Optional[datetime.datetime] = None, + verification_status: bool = False, + ): + """Store verification data (C0:AV). + + Args: + verification_subject: verification subject. Default 0. Must be between 0 and 24. + date: verification date. Defaults to now. + verification_status: verification status. + """ + + if date is None: + date = datetime.datetime.now() + if not 0 <= verification_subject <= 24: + raise ValueError("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 (C0:AT).""" + + return await self.send_command(module="C0", command="AT") + + async def save_download_date(self, date: Optional[datetime.datetime] = None): + """Save Download date (C0:AO). + + Args: + date: download date. Default now. + """ + + if date is None: + date = datetime.datetime.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 (C0:BT). + + 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_x_offset_x_axis_iswap(self, x_offset: int): + """Set X-offset X-axis <-> iSWAP (C0:AG). + + 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 (C0:AF). + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): + """Set X-offset X-axis <-> CoRe 96 head (C0:AF). + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def save_pip_channel_validation_status(self, validation_status: bool = False): + """Save PIP channel validation status (C0:AJ). + + 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 (C0:AE). + + Args: + validation_status: XL channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AE", + tx=validation_status, + ) + + async def configure_node_names(self): + """Configure node names (C0:AJ).""" + + 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 (C0:DD). + + Args: + data_index: data index. Must be between 0 and 9. Default 0. + data_stream: data stream (12 characters). Default . + """ + + if not 0 <= data_index <= 9: + raise ValueError("data_index must be between 0 and 9") + if len(data_stream) != 12: + raise ValueError("data_stream must be 12 chars") + + return await self.send_command( + module="C0", + command="DD", + vi=data_index, + vj=data_stream, + ) + + async def request_technical_status_of_assemblies(self): + """Request Technical status of assemblies (C0:QT).""" + + # TODO: parse res + return await self.send_command(module="C0", command="QT") + + async def request_installation_data(self): + """Request installation data (C0:RI).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RI") + + async def request_device_serial_number(self) -> str: + """Request device serial number (C0:RI).""" + return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore + + async def request_download_date(self): + """Request download date (C0:RO).""" + + # 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 (C0:RO). + + Args: + verification_subject: verification subject. Must be between 0 and 24. Default 0. + """ + + if not 0 <= verification_subject <= 24: + raise ValueError("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 (C0:RS).""" + + # 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 (C0:RJ).""" + + # 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 (C0:UJ).""" + + # TODO: parse res + return await self.send_command(module="C0", command="UJ") + + async def request_node_names(self): + """Request node names (C0:RK).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RK") + + async def request_deck_data(self): + """Request deck data (C0:VD).""" + + # TODO: parse res + return await self.send_command(module="C0", command="VD") + + # -- area reservation and configuration -- + + 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. + """ + + if not 0 <= taken_area_identification_number <= 9999: + raise ValueError("taken_area_identification_number must be between 0 and 9999") + if not 0 <= taken_area_left_margin <= 99: + raise ValueError("taken_area_left_margin must be between 0 and 99") + if not 0 <= taken_area_left_margin_direction <= 1: + raise ValueError("taken_area_left_margin_direction must be between 0 and 1") + if not 0 <= taken_area_size <= 50000: + raise ValueError("taken_area_size must be between 0 and 50000") + if not 0 <= arm_preposition_mode_related_to_taken_areas <= 2: + raise ValueError("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 99. Default 0. + """ + + if not 0 <= taken_area_identification_number <= 99: + raise ValueError("taken_area_identification_number must be between 0 and 99") + + 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") + + 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: float = 1340.0, + right_x_drive_configuration_byte_1: int = 0, + right_x_drive_configuration_byte_2: int = 0, + minimal_iswap_collision_free_position: float = 350.0, + maximal_iswap_collision_free_position: float = 1140.0, + left_x_arm_width: float = 370.0, + right_x_arm_width: float = 370.0, + num_pip_channels: int = 0, + num_xl_channels: int = 0, + num_robotic_channels: int = 0, + minimal_raster_pitch_of_pip_channels: float = 9.0, + minimal_raster_pitch_of_xl_channels: float = 36.0, + minimal_raster_pitch_of_robotic_channels: float = 36.0, + pip_maximal_y_position: float = 606.5, + left_arm_minimal_y_position: float = 6.0, + right_arm_minimal_y_position: float = 6.0, + ): + """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 1 + and 54. Default 54. + tip_waste_x_position: tip waste X-position [mm]. Must be between 100 and + 2500. Default 1340. + right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see + xl parameter bits). Must be between 0 and 1. Default 0. + right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see + xn parameter bits). Must be between 0 and 1. Default 0. + minimal_iswap_collision_free_position: minimal iSWAP collision free position [mm]. + Must be between 0 and 3000. Default 350. + maximal_iswap_collision_free_position: maximal iSWAP collision free position [mm]. + Must be between 0 and 3000. Default 1140. + left_x_arm_width: width of left X arm [mm]. Must be between 0 and 999.9. Default 370. + right_x_arm_width: width of right X arm [mm]. Must be between 0 and 999.9. Default 370. + 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 [mm]. + Must be between 0 and 99.9. Default 9. + minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [mm]. + Must be between 0 and 99.9. Default 36. + minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [mm]. + Must be between 0 and 99.9. Default 36. + pip_maximal_y_position: PIP maximal Y position [mm]. Must be between 0 and 999.9. + Default 606.5. + left_arm_minimal_y_position: left arm minimal Y position [mm]. Must be between 0 and 999.9. + Default 6. + right_arm_minimal_y_position: right arm minimal Y position [mm]. Must be between 0 + and 999.9. Default 6. + """ + + if not 10 <= instrument_size_in_slots_x_range <= 99: + raise ValueError("instrument_size_in_slots_x_range must be between 10 and 99") + if not 1 <= auto_load_size_in_slots <= 54: + raise ValueError("auto_load_size_in_slots must be between 1 and 54") + if not 100 <= tip_waste_x_position <= 2500: + raise ValueError("tip_waste_x_position must be between 100 and 2500") + if not 0 <= right_x_drive_configuration_byte_1 <= 1: + raise ValueError("right_x_drive_configuration_byte_1 must be between 0 and 1") + if not 0 <= right_x_drive_configuration_byte_2 <= 1: + raise ValueError("right_x_drive_configuration_byte_2 must be between 0 and 1") + if not 0 <= minimal_iswap_collision_free_position <= 3000: + raise ValueError("minimal_iswap_collision_free_position must be between 0 and 3000") + if not 0 <= maximal_iswap_collision_free_position <= 3000: + raise ValueError("maximal_iswap_collision_free_position must be between 0 and 3000") + if not 0 <= left_x_arm_width <= 999.9: + raise ValueError("left_x_arm_width must be between 0 and 999.9") + if not 0 <= right_x_arm_width <= 999.9: + raise ValueError("right_x_arm_width must be between 0 and 999.9") + if not 0 <= num_pip_channels <= 16: + raise ValueError("num_pip_channels must be between 0 and 16") + if not 0 <= num_xl_channels <= 8: + raise ValueError("num_xl_channels must be between 0 and 8") + if not 0 <= num_robotic_channels <= 8: + raise ValueError("num_robotic_channels must be between 0 and 8") + if not 0 <= minimal_raster_pitch_of_pip_channels <= 99.9: + raise ValueError("minimal_raster_pitch_of_pip_channels must be between 0 and 99.9") + if not 0 <= minimal_raster_pitch_of_xl_channels <= 99.9: + raise ValueError("minimal_raster_pitch_of_xl_channels must be between 0 and 99.9") + if not 0 <= minimal_raster_pitch_of_robotic_channels <= 99.9: + raise ValueError("minimal_raster_pitch_of_robotic_channels must be between 0 and 99.9") + if not 0 <= pip_maximal_y_position <= 999.9: + raise ValueError("pip_maximal_y_position must be between 0 and 999.9") + if not 0 <= left_arm_minimal_y_position <= 999.9: + raise ValueError("left_arm_minimal_y_position must be between 0 and 999.9") + if not 0 <= right_arm_minimal_y_position <= 999.9: + raise ValueError("right_arm_minimal_y_position must be between 0 and 999.9") + + 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=round(tip_waste_x_position * 10), + xr=right_x_drive_configuration_byte_1, + xo=right_x_drive_configuration_byte_2, + xm=round(minimal_iswap_collision_free_position * 10), + xx=round(maximal_iswap_collision_free_position * 10), + xu=round(left_x_arm_width * 10), + xv=round(right_x_arm_width * 10), + kp=num_pip_channels, + kc=num_xl_channels, + kr=num_robotic_channels, + ys=round(minimal_raster_pitch_of_pip_channels * 10), + kl=round(minimal_raster_pitch_of_xl_channels * 10), + km=round(minimal_raster_pitch_of_robotic_channels * 10), + ym=round(pip_maximal_y_position * 10), + yu=round(left_arm_minimal_y_position * 10), + yx=round(right_arm_minimal_y_position * 10), + ) + + async def pre_initialize_instrument(self): + """Pre-initialize instrument""" + return await self.send_command(module="C0", command="VI", read_timeout=300) + + # -- PIP channel helpers --------------------------------------------------- + + y_drive_mm_per_increment = 0.046302082 + + @staticmethod + def channel_id(channel_idx: int) -> str: + """Return the firmware module identifier for a PIP channel. + + Args: + channel_idx: 0-indexed channel index (0 = backmost). + + Returns: + Module string like ``"P1"`` ... ``"PG"``. + """ + channel_ids = "123456789ABCDEFG" + return "P" + channel_ids[channel_idx] + + @staticmethod + def y_drive_increment_to_mm(value_increments: int) -> float: + """Convert Y-axis hardware increments to mm.""" + return round(value_increments * STARDriver.y_drive_mm_per_increment, 2) + + async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: + """Query the minimum Y spacing for a single channel. + + Args: + channel_idx: 0-indexed channel index. + + 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 the minimum Y spacing for all channels 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(channel_idx=idx) + for idx in range(self.num_channels) + ) + ) + ) + + def _min_spacing_between(self, i: int, j: int) -> float: + """Return the conservative minimum Y spacing required between channels *i* and *j*. + + For adjacent channels, the constraint is the larger of the two channels' individual minimum + spacings, ceiling'd to 1 decimal place for safe movement. + + For non-adjacent channels, the spacing is the sum of all intermediate adjacent-pair spacings. + """ + if not self._channels_minimum_y_spacing: + if self.extended_conf is not None: + return abs(j - i) * self.extended_conf.min_raster_pitch_pip_channels + return abs(j - i) * 9.0 + + lo, hi = min(i, j), max(i, j) + if hi - lo == 1: + 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)) diff --git a/pylabrobot/hamilton/liquid_handlers/star/errors.py b/pylabrobot/hamilton/liquid_handlers/star/errors.py new file mode 100644 index 00000000000..07f1c2ed909 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/errors.py @@ -0,0 +1,873 @@ +from abc import ABCMeta +from typing import Dict, Optional, Type + +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.resources.errors import ( + HasTipError, + NoTipError, + TooLittleLiquidError, + TooLittleVolumeError, +) + + +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 = {36: "Hamilton will not run while the hood is open"} + 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 = { + 20: "No communication to EEPROM", + 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", + } + elif module_identifier == "R0": # iswap + 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", + } + + 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: # No error + 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 diff --git a/pylabrobot/hamilton/liquid_handlers/star/fw_parsing.py b/pylabrobot/hamilton/liquid_handlers/star/fw_parsing.py new file mode 100644 index 00000000000..95442089fb4 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/fw_parsing.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"(20\d{2})[._-](\d{2})[._-](\d{2})", 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"(20\d{2})_Q([1-4])", 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"(20\d{2})", 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/liquid_handlers/star/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py new file mode 100644 index 00000000000..acf81447860 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py @@ -0,0 +1,989 @@ +"""STAR Head96 backend: translates Head96 operations into STAR firmware commands.""" + +from __future__ import annotations + +import datetime +import logging +from contextlib import contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Literal, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend +from pylabrobot.capabilities.liquid_handling.standard import ( + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.hamilton import HamiltonTip, TipSize + +from .pip_backend import _dispensing_mode_for_op # noqa: F401 + +if TYPE_CHECKING: + from pylabrobot.resources.hamilton import HamiltonDeck + + from .driver import STARDriver + +logger = logging.getLogger(__name__) + +# Conversion factors for 96-Head (mm per increment / uL per increment) +_Z_DRIVE_MM_PER_INCREMENT = 0.005 +_Y_DRIVE_MM_PER_INCREMENT = 0.015625 +_DISPENSING_DRIVE_MM_PER_INCREMENT = 0.001025641026 +_DISPENSING_DRIVE_UL_PER_INCREMENT = 0.019340933 +_SQUEEZER_DRIVE_MM_PER_INCREMENT = 0.0002086672009 + + +def _channel_pattern_to_hex(pattern: List[bool]) -> str: + """Convert a list of 96 booleans to the hex string expected by firmware.""" + if len(pattern) != 96: + raise ValueError("channel_pattern must be a list of 96 boolean values") + channel_pattern_bin_str = reversed(["1" if x else "0" for x in pattern]) + return hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + +class STARHead96Backend(Head96Backend): + """Translates Head96 operations into STAR firmware commands via the driver.""" + + def __init__(self, driver: STARDriver, deck: "HamiltonDeck", traversal_height: float = 245.0): + self.driver = driver + self.deck = deck + self.traversal_height = traversal_height + + @contextmanager + def use_traversal_height(self, height: float): + """Temporarily override the traversal height for all Head96 operations.""" + original = self.traversal_height + self.traversal_height = height + try: + yield + finally: + self.traversal_height = original + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + """Initialize the 96-head if not already initialized, and cache firmware info. + + Mirrors the legacy initialization flow: + 1. Check if already initialized (H0:QW). + 2. If not, send the initialize command (C0:EI). + 3. Cache firmware version and configuration for version-specific behavior. + """ + already_initialized = await self.request_initialization_status() + if not already_initialized: + trash96 = self.deck.get_trash_area96() + loc = self._position_96_head_in_resource(trash96) + await self.initialize(x=loc.x, y=loc.y, z=loc.z) + + # Cache firmware version and configuration for version-specific behavior + self.fw_version = await self.request_firmware_version() + + async def _on_stop(self): + """Move to Z safety and park the 96-head on shutdown.""" + try: + await self.move_to_z_safety() + except Exception: + logger.warning("Failed to move 96-head to Z safety during stop", exc_info=True) + try: + await self.park() + except Exception: + logger.warning("Failed to park 96-head during stop", exc_info=True) + + # --------------------------------------------------------------------------- + # Initialization & status + # --------------------------------------------------------------------------- + + async def request_firmware_version(self) -> datetime.date: + """Request 96-head firmware version (H0:RF).""" + from pylabrobot.hamilton.liquid_handlers.star.fw_parsing import ( + parse_star_firmware_version_date, + ) + + resp = await self.driver.send_command(module="H0", command="RF") + return parse_star_firmware_version_date(str(resp)) + + async def request_initialization_status(self) -> bool: + """Request 96-head initialization status (H0:QW). + + Returns: + True if the 96-head is initialized, False otherwise. + """ + response = await self.driver.send_command(module="H0", command="QW", fmt="qw#") + if response is None: + return False + return bool(response.get("qw", 0) == 1) + + async def initialize( + self, + x: float = 0, + y: float = 0, + z: float = 0, + minimum_height_command_end: Optional[float] = None, + ): + """Initialize the CoRe 96 Head (C0:EI). + + This sends tips to the specified position (typically the trash area) and + initializes all axes. + + Args: + x: X position in mm for A1 channel of the 96-head during initialization. + y: Y position in mm for A1 channel of the 96-head during initialization. + z: Z position in mm. Default 0. + minimum_height_command_end: Minimum Z height in mm at command end. + If None, uses the backend's ``traversal_height``. + """ + ze = ( + minimum_height_command_end + if minimum_height_command_end is not None + else self.traversal_height + ) + + await self.driver.send_command( + module="C0", + command="EI", + read_timeout=60, + xs=f"{abs(round(x * 10)):05}", + xd=0 if x >= 0 else 1, + yh=f"{abs(round(y * 10)):04}", + za=f"{round(z * 10):04}", + ze=f"{round(ze * 10):04}", + ) + + async def initialize_dispensing_drive_and_squeezer( + self, + squeezer_speed: float = 15.0, + squeezer_acceleration: float = 62.0, + squeezer_current_limit: int = 15, + dispensing_drive_current_limit: int = 7, + ): + """Initialize 96-head's dispensing drive AND squeezer drive (H0:PI). + + This command: + - Drops any tips that might be on the channels (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 in mm/sec. Must be between 0.01 and 16.69. + squeezer_acceleration: Acceleration of the movement in mm/sec**2. Must be between + 1.04 and 62.6. + squeezer_current_limit: Current limit for the squeezer drive (1-15). + dispensing_drive_current_limit: Current limit for the dispensing drive (1-15). + """ + if not (0.01 <= squeezer_speed <= 16.69): + raise ValueError( + f"squeezer_speed must be between 0.01 and 16.69 mm/sec, got {squeezer_speed}" + ) + if not (1.04 <= squeezer_acceleration <= 62.6): + raise ValueError( + f"squeezer_acceleration must be between 1.04 and 62.6 mm/sec**2, got {squeezer_acceleration}" + ) + if not (1 <= squeezer_current_limit <= 15): + raise ValueError( + f"squeezer_current_limit must be between 1 and 15, got {squeezer_current_limit}" + ) + if not (1 <= dispensing_drive_current_limit <= 15): + raise ValueError( + f"dispensing_drive_current_limit must be between 1 and 15, got {dispensing_drive_current_limit}" + ) + + squeezer_speed_inc = round(squeezer_speed / _SQUEEZER_DRIVE_MM_PER_INCREMENT) + squeezer_accel_inc = round(squeezer_acceleration / _SQUEEZER_DRIVE_MM_PER_INCREMENT) + + await self.driver.send_command( + module="H0", + command="PI", + sv=f"{squeezer_speed_inc:05}", + sr=f"{squeezer_accel_inc:06}", + sw=f"{squeezer_current_limit:02}", + dw=f"{dispensing_drive_current_limit:02}", + ) + + # --------------------------------------------------------------------------- + # Movement commands + # --------------------------------------------------------------------------- + + async def move_to_z_safety(self): + """Move 96-Head to Z safety coordinate, i.e. z=342.5 mm (C0:EV).""" + await self.driver.send_command(module="C0", command="EV") + + async def park(self): + """Park the 96-head (H0:MO). + + Uses firmware default speeds and accelerations. + """ + await self.driver.send_command(module="H0", command="MO") + + async def move_to_coordinate( + self, + coordinate: Coordinate, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + """Move 96-Head to a defined coordinate (C0:EM). + + Args: + coordinate: Coordinate of A1 in mm. If tips are present, refers to tip bottom; + if not present, refers to channel bottom. + minimum_height_at_beginning_of_a_command: Minimum Z height in mm before lateral + movement begins. Must be between 0 and 342.5. + """ + if not (0 <= minimum_height_at_beginning_of_a_command <= 342.5): + raise ValueError("minimum_height_at_beginning_of_a_command must be between 0 and 342.5") + + await self.driver.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}", + ) + + async def move_y( + self, + y: float, + speed: float = 300.0, + acceleration: float = 300.0, + current_protection_limiter: int = 15, + ): + """Move the 96-head to a specified Y-axis coordinate (H0:YA). + + Args: + y: Target Y coordinate in mm. Valid range: [93.75, 562.5]. + speed: Movement speed in mm/sec. Valid range: [0.78125, 625.0]. + acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]. + current_protection_limiter: Motor current limit (0-15, hardware units). + """ + if not (93.75 <= y <= 562.5): + raise ValueError("y must be between 93.75 and 562.5 mm") + if not (0.78125 <= speed <= 625.0): + raise ValueError("speed must be between 0.78125 and 625.0 mm/sec") + if not (78.125 <= acceleration <= 781.25): + raise ValueError("acceleration must be between 78.125 and 781.25 mm/sec**2") + if not (isinstance(current_protection_limiter, int) and 0 <= current_protection_limiter <= 15): + raise ValueError("current_protection_limiter must be an integer between 0 and 15") + + y_inc = round(y / _Y_DRIVE_MM_PER_INCREMENT) + speed_inc = round(speed / _Y_DRIVE_MM_PER_INCREMENT) + accel_inc = round(acceleration / _Y_DRIVE_MM_PER_INCREMENT) + + await self.driver.send_command( + module="H0", + command="YA", + ya=f"{y_inc:05}", + yv=f"{speed_inc:05}", + yr=f"{accel_inc:05}", + yw=f"{current_protection_limiter:02}", + ) + + async def move_z( + self, + z: float, + speed: float = 80.0, + acceleration: float = 300.0, + current_protection_limiter: int = 15, + ): + """Move the 96-head to a specified Z-axis coordinate (H0:ZA). + + Args: + z: Target Z coordinate in mm. Valid range: [180.5, 342.5]. + speed: Movement speed in mm/sec. Valid range: [0.25, 100.0]. + acceleration: Movement acceleration in mm/sec**2. Valid range: [25.0, 500.0]. + current_protection_limiter: Motor current limit (0-15, hardware units). + """ + if not (180.5 <= z <= 342.5): + raise ValueError("z must be between 180.5 and 342.5 mm") + if not (0.25 <= speed <= 100.0): + raise ValueError("speed must be between 0.25 and 100.0 mm/sec") + if not (25.0 <= acceleration <= 500.0): + raise ValueError("acceleration must be between 25.0 and 500.0 mm/sec**2") + if not (isinstance(current_protection_limiter, int) and 0 <= current_protection_limiter <= 15): + raise ValueError("current_protection_limiter must be an integer between 0 and 15") + + z_inc = round(z / _Z_DRIVE_MM_PER_INCREMENT) + speed_inc = round(speed / _Z_DRIVE_MM_PER_INCREMENT) + accel_inc = round(acceleration / _Z_DRIVE_MM_PER_INCREMENT) + + await self.driver.send_command( + module="H0", + command="ZA", + za=f"{z_inc:05}", + zv=f"{speed_inc:05}", + zr=f"{accel_inc:06}", + zw=f"{current_protection_limiter:02}", + ) + + async def dispensing_drive_move_to_position( + self, + position: float, + 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 (H0:DQ). + + Args: + position: Position in uL. Must be between 0 and 1244.59. + speed: Speed in uL/s. Must be between 0.1 and 1063.75. + stop_speed: Stop speed in uL/s. Must be between 0 and 1063.75. + acceleration: Acceleration in uL/s**2. Must be between 96.7 and 17406.84. + current_protection_limiter: Current protection limiter (0-15). + """ + if not (0 <= position <= 1244.59): + 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") + + pos_inc = round(position / _DISPENSING_DRIVE_UL_PER_INCREMENT) + speed_inc = round(speed / _DISPENSING_DRIVE_UL_PER_INCREMENT) + stop_inc = round(stop_speed / _DISPENSING_DRIVE_UL_PER_INCREMENT) + accel_inc = round(acceleration / _DISPENSING_DRIVE_UL_PER_INCREMENT) + + await self.driver.send_command( + module="H0", + command="DQ", + dq=f"{pos_inc:05}", + dv=f"{speed_inc:05}", + du=f"{stop_inc:05}", + dr=f"{accel_inc:06}", + dw=f"{current_protection_limiter:02}", + ) + + async def dispensing_drive_move_to_home_volume(self): + """Move the 96-head dispensing drive into its home position, vol=0.0 uL (H0:DL). + + .. warning:: + This firmware command is known to be broken: the 96-head dispensing drive + cannot reach vol=0.0 uL, which typically raises a position-out-of-permitted-area + error. + """ + logger.warning( + "dispensing_drive_move_to_home_volume is a known broken firmware command: " + "the 96-head dispensing drive cannot reach vol=0.0 uL." + ) + await self.driver.send_command(module="H0", command="DL") + + # --------------------------------------------------------------------------- + # Query commands + # --------------------------------------------------------------------------- + + async def request_position(self) -> Coordinate: + """Request position of the CoRe 96 Head (C0:QI). + + Returns: + Coordinate: x, y, z in mm. The position of A1, considering tip length + if tips are mounted. + """ + resp = await self.driver.send_command(module="C0", command="QI", fmt="xs#####xd#yh####za####") + if resp is None: + return Coordinate(x=0, y=0, z=0) + + x = resp["xs"] / 10 + y = resp["yh"] / 10 + z = resp["za"] / 10 + x = x if resp["xd"] == 0 else -x + + return Coordinate(x=x, y=y, z=z) + + async def request_tip_presence(self) -> int: + """Request tip presence on the 96-Head (C0:QH). + + Note: This queries the firmware's internal memory. It does not directly + sense whether tips are physically present. + + Returns: + 0 = no tips, 1 = firmware believes tips are on the 96-head. + """ + resp = await self.driver.send_command(module="C0", command="QH", fmt="qh#") + if resp is None: + return 0 + return int(resp["qh"]) + + async def request_tadm_status(self) -> int: + """Request CoRe 96 Head channel TADM status (C0:VC). + + Returns: + 0 = off, 1 = on. + """ + resp = await self.driver.send_command(module="C0", command="VC", fmt="qx#") + if resp is None: + return 0 + return int(resp["qx"]) + + async def request_tadm_error_status(self) -> dict: + """Request CoRe 96 Head channel TADM error status (C0:VB). + + Returns: + Dictionary with error pattern (0 = no error). + """ + resp = await self.driver.send_command(module="C0", command="VB", fmt="vb" + "&" * 24) + if resp is None: + return {} + return dict(resp) + + async def dispensing_drive_request_position_mm(self) -> float: + """Request 96-head dispensing drive position in mm (H0:RD).""" + resp = await self.driver.send_command(module="H0", command="RD", fmt="rd######") + if resp is None: + return 0.0 + return float(round(resp["rd"] * _DISPENSING_DRIVE_MM_PER_INCREMENT, 2)) + + async def dispensing_drive_request_position_uL(self) -> float: + """Request 96-head dispensing drive position in uL.""" + position_mm = await self.dispensing_drive_request_position_mm() + increment = round(position_mm / _DISPENSING_DRIVE_MM_PER_INCREMENT) + return round(increment * _DISPENSING_DRIVE_UL_PER_INCREMENT, 2) + + # --------------------------------------------------------------------------- + # Pick up tips + # --------------------------------------------------------------------------- + + @dataclass + class PickUpTips96Params(BackendParams): + """STAR-specific parameters for 96-head tip pickup. + + Args: + tip_pickup_method: Tip pickup strategy. + - ``"from_rack"``: standard pickup from a tip rack; moves the plunger down + before mounting tips. + - ``"from_waste"``: moves plunger up, mounts tips, retracts ~10 mm, moves + plunger down, then moves to traversal height. + - ``"full_blowout"``: moves plunger up, mounts tips, then moves to traversal + height. + minimum_height_command_end: Minimal Z height in mm at command end. If None, uses + the backend's ``traversal_height``. Must be between 0 and 342.5. + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm + before lateral movement begins. If None, uses the backend's + ``traversal_height``. Must be between 0 and 342.5. + alignment_tipspot_identifier: The tip spot identifier (e.g. ``"A1"``) used to + align the 96-head's A1 channel. Allowed range is ``"A1"`` to ``"H12"``. + """ + + 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 + alignment_tipspot_identifier: str = "A1" + + async def pick_up_tips96( + self, pickup: PickupTipRack, backend_params: Optional[BackendParams] = None + ): + """Pick up tips using the 96 head. + + Firmware command: C0 EP + """ + await self.driver.ensure_iswap_parked() + logger.info("[STAR 96] pick_up_tips: resource=%s", pickup.resource.name) + if not isinstance(backend_params, STARHead96Backend.PickUpTips96Params): + backend_params = STARHead96Backend.PickUpTips96Params() + + tip_pickup_method = backend_params.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.driver.request_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 position using absolute coordinates (deck is at origin) + alignment_tipspot = pickup.resource.get_item(backend_params.alignment_tipspot_identifier) + tip_spot_z = alignment_tipspot.get_absolute_location().z + pickup.offset.z + z_pickup_position = tip_spot_z + tip_engage_height_from_tipspot + + pickup_position = alignment_tipspot.get_absolute_location(x="c", y="c") + pickup.offset + pickup_position.z = round(z_pickup_position, 2) + + traversal = self.traversal_height + + if tip_pickup_method == "from_rack": + # Move the dispensing drive down before pickup. + # The STAR will not automatically move the dispensing drive down if it is still up. + # See https://github.com/PyLabRobot/pylabrobot/pull/835 + # + # Pre-computed increment values (uL / 0.019340933): + # position=218.19uL -> 11281, speed=261.1uL/s -> 13500, + # stop_speed=0 -> 0, acceleration=17406.84uL/s^2 -> 900000 + await self.driver.send_command( + module="H0", + command="DQ", + dq="11281", + dv="13500", + du="00000", + dr="900000", + dw="15", + ) + + await self.driver.send_command( + module="C0", + command="EP", + xs=f"{abs(round(pickup_position.x * 10)):05}", + xd=0 if pickup_position.x >= 0 else 1, + yh=f"{round(pickup_position.y * 10):04}", + tt=f"{ttti:02}", + wu={"from_rack": 0, "from_waste": 1, "full_blowout": 2}[tip_pickup_method], + za=f"{round(pickup_position.z * 10):04}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.minimum_height_command_end or traversal) * 10):04}", + ) + + # --------------------------------------------------------------------------- + # Drop tips + # --------------------------------------------------------------------------- + + @dataclass + class DropTips96Params(BackendParams): + """STAR-specific parameters for 96-head tip drop. + + Args: + minimum_height_command_end: Minimal Z height in mm at command end. If None, uses + the backend's ``traversal_height``. Must be between 0 and 342.5. + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm + before lateral movement begins. If None, uses the backend's + ``traversal_height``. Must be between 0 and 342.5. + alignment_tipspot_identifier: The tip spot identifier (e.g. ``"A1"``) used to + align the 96-head's A1 channel. Allowed range is ``"A1"`` to ``"H12"``. + """ + + minimum_height_command_end: Optional[float] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + alignment_tipspot_identifier: str = "A1" + + async def drop_tips96(self, drop: DropTipRack, backend_params: Optional[BackendParams] = None): + """Drop tips from the 96 head. + + Firmware command: C0 ER + """ + await self.driver.ensure_iswap_parked() + logger.info("[STAR 96] drop_tips: resource=%s", drop.resource.name) + if not isinstance(backend_params, STARHead96Backend.DropTips96Params): + backend_params = STARHead96Backend.DropTips96Params() + + from pylabrobot.resources import TipRack + + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item(backend_params.alignment_tipspot_identifier) + position = tip_spot_a1.get_absolute_location(x="c", y="c") + drop.offset + tip_rack = tip_spot_a1.parent + if tip_rack is None: + raise ValueError("Tip spot parent (tip rack) must not be None") + position.z = tip_rack.get_absolute_location().z + 1.45 + else: + # Drop into trash or other resource: center the head in the resource. + position = self._position_96_head_in_resource(drop.resource) + drop.offset + + traversal = self.traversal_height + + await self.driver.send_command( + module="C0", + command="ER", + xs=f"{abs(round(position.x * 10)):05}", + xd=0 if position.x >= 0 else 1, + yh=f"{round(position.y * 10):04}", + za=f"{round(position.z * 10):04}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.minimum_height_command_end or traversal) * 10):04}", + ) + + # --------------------------------------------------------------------------- + # Aspirate + # --------------------------------------------------------------------------- + + @dataclass + class Aspirate96Params(BackendParams): + """STAR-specific parameters for 96-head aspiration. + + Args: + use_lld: If True, use gamma liquid level detection. If False, use the + liquid height from the aspiration operation. + aspiration_type: Type of aspiration (0 = simple, 1 = sequence, 2 = cup emptied). + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm + before lateral movement. If None, uses the backend's ``traversal_height``. + min_z_endpos: Minimum Z position in mm at end of command. If None, uses the + backend's ``traversal_height``. + lld_search_height: LLD search height in mm. Default 199.9. + minimum_height: Minimum height (maximum immersion depth) in mm. If None, uses + the container bottom Z. + second_section_height: Tube 2nd section height measured from minimum_height in mm. + Default 3.2. + second_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter. Default 618.0. + immersion_depth: Immersion depth in mm. Positive = go deeper into liquid, + negative = go up out of liquid. Default 0. + surface_following_distance: Surface following distance during aspiration in mm. + Default 0. + transport_air_volume: Transport air volume in uL. Default 5.0. + pre_wetting_volume: Pre-wetting volume in uL. Default 5.0. + gamma_lld_sensitivity: Gamma LLD sensitivity (1 = high, 4 = low). Default 1. + swap_speed: Swap speed (on leaving liquid) in mm/s. Must be between 0.3 and + 160.0. Default 2.0. + settling_time: Settling time in seconds. Default 1.0. + mix_position_from_liquid_surface: Mix position in Z direction from liquid surface + in mm. Default 0. + mix_surface_following_distance: Surface following distance during mix in mm. + Default 0. + limit_curve_index: Limit curve index for TADM. Must be between 0 and 999. + Default 0. + pull_out_distance_transport_air: Distance in mm to pull out for transport air. + Default 10. + tadm_algorithm: Whether to use the TADM algorithm. Default False. + recording_mode: Recording mode (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Default 0. + """ + + use_lld: bool = False + 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 + pull_out_distance_transport_air: float = 10 + tadm_algorithm: bool = False + recording_mode: int = 0 + + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate using the Core96 head. + + Firmware command: C0 EA + """ + await self.driver.ensure_iswap_parked() + if not isinstance(backend_params, STARHead96Backend.Aspirate96Params): + backend_params = STARHead96Backend.Aspirate96Params() + + # Compute position + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + if plate is None: + raise ValueError("MultiHeadAspirationPlate well parent must not be None") + 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_absolute_location(x="c", y="c") + + Coordinate(z=ref_well.material_z_thickness) + + aspiration.offset + ) + else: + # Container (trough): center the head + 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_absolute_location(z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + aspiration.offset + ) + + liquid_height = position.z + (aspiration.liquid_height or 0) + + volume = aspiration.volume + flow_rate = aspiration.flow_rate or 250 + blow_out_air_volume = aspiration.blow_out_air_volume or 0 + + if isinstance(aspiration, MultiHeadAspirationPlate): + if aspiration.wells[0].parent is None: + raise ValueError("Well has no parent resource") + resource_name = aspiration.wells[0].parent.name + else: + resource_name = aspiration.container.name + logger.info( + "[STAR 96] aspirate: resource=%s volume=%.2f flow_rate=%.2f", resource_name, volume, flow_rate + ) + + traversal = self.traversal_height + + immersion_depth = backend_params.immersion_depth + immersion_depth_direction = 0 if immersion_depth >= 0 else 1 + + await self.driver.send_command( + module="C0", + command="EA", + aa=backend_params.aspiration_type, + xs=f"{abs(round(position.x * 10)):05}", + xd=0 if position.x >= 0 else 1, + yh=f"{round(position.y * 10):04}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.min_z_endpos or traversal) * 10):04}", + lz=f"{round(backend_params.lld_search_height * 10):04}", + zt=f"{round(liquid_height * 10):04}", + pp=f"{round(backend_params.pull_out_distance_transport_air * 10):04}", + zm=f"{round((backend_params.minimum_height or position.z) * 10):04}", + zv=f"{round(backend_params.second_section_height * 10):04}", + zq=f"{round(backend_params.second_section_ratio * 10):05}", + iw=f"{round(abs(immersion_depth) * 10):03}", + ix=immersion_depth_direction, + fh=f"{round(backend_params.surface_following_distance * 10):03}", + af=f"{round(volume * 10):05}", + ag=f"{round(flow_rate * 10):04}", + vt=f"{round(backend_params.transport_air_volume * 10):03}", + bv=f"{round(blow_out_air_volume * 10):05}", + wv=f"{round(backend_params.pre_wetting_volume * 10):05}", + cm=int(backend_params.use_lld), + cs=backend_params.gamma_lld_sensitivity, + bs=f"{round(backend_params.swap_speed * 10):04}", + wh=f"{round(backend_params.settling_time * 10):02}", + hv=f"{round(aspiration.mix.volume * 10):05}" if aspiration.mix is not None else "00000", + hc=f"{aspiration.mix.repetitions:02}" if aspiration.mix is not None else "00", + hp=f"{round(backend_params.mix_position_from_liquid_surface * 10):03}", + mj=f"{round(backend_params.mix_surface_following_distance * 10):03}", + hs=f"{round(aspiration.mix.flow_rate * 10):04}" if aspiration.mix is not None else "1200", + cw=_channel_pattern_to_hex([True] * 96), + cr=f"{backend_params.limit_curve_index:03}", + cj=backend_params.tadm_algorithm, + cx=backend_params.recording_mode, + ) + + # --------------------------------------------------------------------------- + # Dispense + # --------------------------------------------------------------------------- + + @dataclass + class Dispense96Params(BackendParams): + """STAR-specific parameters for 96-head dispense. + + Args: + jet: Whether to use jet dispensing mode. + empty: Whether to use empty tip mode. + blow_out: Whether to blow out after dispensing. + use_lld: If True, use gamma liquid level detection. If False, use the + liquid height from the dispense operation. + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm + before lateral movement. If None, uses the backend's ``traversal_height``. + min_z_endpos: Minimum Z position in mm at end of command. If None, uses the + backend's ``traversal_height``. + lld_search_height: LLD search height in mm. Default 199.9. + minimum_height: Minimum height (maximum immersion depth) in mm. If None, uses + the container bottom Z. + second_section_height: Tube 2nd section height measured from minimum_height in mm. + Default 3.2. + second_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter. Default 618.0. + immersion_depth: Immersion depth in mm. Positive = go deeper into liquid, + negative = go up out of liquid. Default 0. + surface_following_distance: Surface following distance during dispensing in mm. + Default 0. + transport_air_volume: Transport air volume in uL. Default 5.0. + gamma_lld_sensitivity: Gamma LLD sensitivity (1 = high, 4 = low). Default 1. + swap_speed: Swap speed (on leaving liquid) in mm/s. Must be between 0.3 and + 160.0. Default 2.0. + settling_time: Settling time in seconds. Default 5.0. + mix_position_from_liquid_surface: Mix position in Z direction from liquid surface + in mm. Default 0. + mix_surface_following_distance: Surface following distance during mix in mm. + Default 0. + limit_curve_index: Limit curve index for TADM. Must be between 0 and 999. + Default 0. + cut_off_speed: Cut-off speed in uL/s. Default 5.0. + stop_back_volume: Stop back volume in uL. Default 0. + pull_out_distance_transport_air: Distance in mm to pull out for transport air. + Default 10. + side_touch_off_distance: Side touch off distance in 0.1 mm units (0 = OFF). + Default 0. + tadm_algorithm: Whether to use the TADM algorithm. Default False. + recording_mode: Recording mode (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Default 0. + """ + + jet: bool = False + empty: bool = False + blow_out: bool = False + 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 = 5.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 + pull_out_distance_transport_air: float = 10 + side_touch_off_distance: int = 0 + tadm_algorithm: bool = False + recording_mode: int = 0 + + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + backend_params: Optional[BackendParams] = None, + ): + """Dispense using the Core96 head. + + Firmware command: C0 ED + """ + await self.driver.ensure_iswap_parked() + if not isinstance(backend_params, STARHead96Backend.Dispense96Params): + backend_params = STARHead96Backend.Dispense96Params() + + # Compute position + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + if plate is None: + raise ValueError("MultiHeadDispensePlate well parent must not be None") + 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_absolute_location(x="c", y="c") + + Coordinate(z=ref_well.material_z_thickness) + + dispense.offset + ) + else: + # Container (trough): center the head + x_width = (12 - 1) * 9 + y_width = (8 - 1) * 9 + 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_absolute_location(z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + dispense.offset + ) + + liquid_height = position.z + (dispense.liquid_height or 0) + + volume = dispense.volume + flow_rate = dispense.flow_rate or 120 + blow_out_air_volume = dispense.blow_out_air_volume or 0 + + if isinstance(dispense, MultiHeadDispensePlate): + if dispense.wells[0].parent is None: + raise ValueError("Well has no parent resource") + resource_name = dispense.wells[0].parent.name + else: + resource_name = dispense.container.name + logger.info( + "[STAR 96] dispense: resource=%s volume=%.2f flow_rate=%.2f", resource_name, volume, flow_rate + ) + + dispense_mode = _dispensing_mode_for_op( + empty=backend_params.empty, + jet=backend_params.jet, + blow_out=backend_params.blow_out, + ) + + traversal = self.traversal_height + + immersion_depth = backend_params.immersion_depth + immersion_depth_direction = 0 if immersion_depth >= 0 else 1 + + await self.driver.send_command( + module="C0", + command="ED", + da=dispense_mode, + xs=f"{abs(round(position.x * 10)):05}", + xd=0 if position.x >= 0 else 1, + yh=f"{round(position.y * 10):04}", + zm=f"{round((backend_params.minimum_height or position.z) * 10):04}", + zv=f"{round(backend_params.second_section_height * 10):04}", + zq=f"{round(backend_params.second_section_ratio * 10):05}", + lz=f"{round(backend_params.lld_search_height * 10):04}", + zt=f"{round(liquid_height * 10):04}", + pp=f"{round(backend_params.pull_out_distance_transport_air * 10):04}", + iw=f"{round(abs(immersion_depth) * 10):03}", + ix=immersion_depth_direction, + fh=f"{round(backend_params.surface_following_distance * 10):03}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.min_z_endpos or traversal) * 10):04}", + df=f"{round(volume * 10):05}", + dg=f"{round(flow_rate * 10):04}", + es=f"{round(backend_params.cut_off_speed * 10):04}", + ev=f"{round(backend_params.stop_back_volume * 10):03}", + vt=f"{round(backend_params.transport_air_volume * 10):03}", + bv=f"{round(blow_out_air_volume * 10):05}", + cm=int(backend_params.use_lld), + cs=backend_params.gamma_lld_sensitivity, + ej=f"{backend_params.side_touch_off_distance:02}", + bs=f"{round(backend_params.swap_speed * 10):04}", + wh=f"{round(backend_params.settling_time * 10):02}", + hv=f"{round(dispense.mix.volume * 10):05}" if dispense.mix is not None else "00000", + hc=f"{dispense.mix.repetitions:02}" if dispense.mix is not None else "00", + hp=f"{round(backend_params.mix_position_from_liquid_surface * 10):03}", + mj=f"{round(backend_params.mix_surface_following_distance * 10):03}", + hs=f"{round(dispense.mix.flow_rate * 10):04}" if dispense.mix is not None else "1200", + cw=_channel_pattern_to_hex([True] * 96), + cr=f"{backend_params.limit_curve_index:03}", + cj=backend_params.tadm_algorithm, + cx=backend_params.recording_mode, + ) + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + @staticmethod + def _position_96_head_in_resource(resource: Resource) -> Coordinate: + """Compute the A1 position for centering the 96-head in a resource.""" + head_size_x = 9 * 11 # 12 channels, 9mm spacing + head_size_y = 9 * 7 # 8 channels, 9mm spacing + channel_size = 9 + loc = resource.get_absolute_location() + 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 diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py new file mode 100644 index 00000000000..9a9b3193aa6 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -0,0 +1,940 @@ +from __future__ import annotations + +import enum +import logging +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Optional, cast + +from pylabrobot.capabilities.arms.backend import OrientableGripperArmBackend +from pylabrobot.capabilities.arms.standard import CartesianPose, GripDirection +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation + +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + +logger = logging.getLogger(__name__) + + +def _direction_degrees_to_grip_direction(degrees: float) -> int: + """Convert rotation angle in degrees to firmware grip_direction (1-4). + + Firmware: 1 = negative Y (front), 2 = positive X (right), + 3 = positive Y (back), 4 = negative X (left). + """ + normalized = round(degrees) % 360 + mapping = {0: 1, 90: 2, 180: 3, 270: 4} + if normalized not in mapping: + raise ValueError(f"grip direction must be a multiple of 90 degrees, got {degrees}") + return mapping[normalized] + + +class iSWAPBackend(OrientableGripperArmBackend): + class RotationDriveOrientation(enum.Enum): + LEFT = 1 + FRONT = 2 + RIGHT = 3 + PARKED_RIGHT = None + + class WristDriveOrientation(enum.Enum): + RIGHT = 1 + STRAIGHT = 2 + LEFT = 3 + REVERSE = 4 + + def __init__(self, driver: STARDriver, traversal_height: float = 280.0): + self.driver = driver + self.traversal_height = traversal_height + self._version: Optional[str] = None + self._parked: Optional[bool] = None + + @property + def version(self) -> str: + """Firmware version string. Available after setup.""" + if self._version is None: + raise RuntimeError("iSWAP version not loaded. Call setup() first.") + return self._version + + @property + def parked(self) -> bool: + return self._parked is True + + @contextmanager + def use_traversal_height(self, height: float): + """Temporarily override the traversal height for all iSWAP operations.""" + original = self.traversal_height + self.traversal_height = height + try: + yield + finally: + self.traversal_height = original + + async def request_gripper_location(self, backend_params=None) -> CartesianPose: + """Request iSWAP grip center position (C0 QG). + + Returns: + CartesianPose with position in mm and a default rotation. + """ + resp = await self.driver.send_command( + module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#" + ) + location = 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), + ) + return CartesianPose(location=location, rotation=Rotation()) + + async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: + already_initialized = await self.request_initialization_status() + if not already_initialized: + await self.initialize() + await self.park() + + if self._version is None: + self._version = await self._request_version() + + async def _request_version(self) -> str: + """Request the iSWAP firmware version from the device.""" + return cast(str, (await self.driver.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + + async def initialize(self) -> None: + """Initialize iSWAP (C0 FI). For standalone configuration only.""" + await self.driver.send_command(module="C0", command="FI") + + async def open_not_initialized_gripper(self) -> None: + """Open gripper when iSWAP is not yet initialized (C0 GI).""" + await self.driver.send_command(module="C0", command="GI") + + async def dangerous_release_brake(self) -> None: + """Release the iSWAP brake (R0 BA). Use with caution.""" + await self.driver.send_command(module="R0", command="BA") + + async def reengage_brake(self) -> None: + """Re-engage the iSWAP brake (R0 BO).""" + await self.driver.send_command(module="R0", command="BO") + + async def initialize_z_axis(self) -> None: + """Initialize the iSWAP Z axis (R0 ZI).""" + await self.driver.send_command(module="R0", command="ZI") + + # -- relative / absolute movement ------------------------------------------ + + async def move_relative_x(self, step_size: float, allow_splitting: bool = False) -> None: + """Move iSWAP X by a relative step (C0 GX). + + Args: + step_size: X step size [mm]. Between -99.9 and 99.9 unless allow_splitting is True. + allow_splitting: Allow splitting into multiple firmware commands. + """ + direction = 0 if step_size >= 0 else 1 + max_step = 99.9 + if abs(step_size) > max_step: + if not allow_splitting: + raise ValueError("step_size must be between -99.9 and 99.9") + first = max_step if step_size > 0 else -max_step + await self.move_relative_x(step_size=first, allow_splitting=True) + remaining = step_size - first + return await self.move_relative_x(remaining, allow_splitting=True) + + await self.driver.send_command( + module="C0", + command="GX", + gx=f"{round(abs(step_size) * 10):03}", + xd=direction, + ) + + async def move_relative_y(self, step_size: float, allow_splitting: bool = False) -> None: + """Move iSWAP Y by a relative step (C0 GY). + + Args: + step_size: Y step size [mm]. Between -99.9 and 99.9 unless allow_splitting is True. + allow_splitting: Allow splitting into multiple firmware commands. + """ + direction = 0 if step_size >= 0 else 1 + max_step = 99.9 + if abs(step_size) > max_step: + if not allow_splitting: + raise ValueError("step_size must be between -99.9 and 99.9") + first = max_step if step_size > 0 else -max_step + await self.move_relative_y(step_size=first, allow_splitting=True) + remaining = step_size - first + return await self.move_relative_y(remaining, allow_splitting=True) + + await self.driver.send_command( + module="C0", + command="GY", + gy=f"{round(abs(step_size) * 10):03}", + yd=direction, + ) + + async def move_relative_z(self, step_size: float, allow_splitting: bool = False) -> None: + """Move iSWAP Z by a relative step (C0 GZ). + + Args: + step_size: Z step size [mm]. Between -99.9 and 99.9 unless allow_splitting is True. + allow_splitting: Allow splitting into multiple firmware commands. + """ + direction = 0 if step_size >= 0 else 1 + max_step = 99.9 + if abs(step_size) > max_step: + if not allow_splitting: + raise ValueError("step_size must be between -99.9 and 99.9") + first = max_step if step_size > 0 else -max_step + await self.move_relative_z(step_size=first, allow_splitting=True) + remaining = step_size - first + return await self.move_relative_z(remaining, allow_splitting=True) + + await self.driver.send_command( + module="C0", + command="GZ", + gz=f"{round(abs(step_size) * 10):03}", + zd=direction, + ) + + async def request_in_parking_position(self) -> dict: + """Request iSWAP parking position status (C0 RG). + + Returns: + Parsed response dict with key ``"rg"`` (0 = not parked, 1 = parked). + """ + return await self.driver.send_command(module="C0", command="RG", fmt="rg#") # type: ignore[no-any-return] + + async def request_initialization_status(self) -> bool: + """Request iSWAP initialization status (R0 QW). + + Returns: + True if iSWAP is fully initialized. + """ + resp = await self.driver.send_command(module="R0", command="QW", fmt="qw#") + return cast(int, resp["qw"]) == 1 + + async def rotation_drive_request_y(self) -> float: + """Request iSWAP rotation drive Y position (center) in mm (R0 RY). + + This is equivalent to the Y location of the iSWAP module. + """ + if not self.driver.extended_conf.left_x_drive.iswap_installed: # type: ignore[union-attr] + raise RuntimeError("iSWAP is not installed") + resp = await self.driver.send_command(module="R0", command="RY", fmt="ry##### (n)") + iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter + return round(self.driver.y_drive_increment_to_mm(iswap_y_pos), 1) + + async def move_x(self, x: float) -> None: + """Move iSWAP X to an absolute position [mm].""" + loc = (await self.request_gripper_location()).location + await self.move_relative_x(step_size=x - loc.x, allow_splitting=True) + + async def move_y(self, y: float) -> None: + """Move iSWAP Y to an absolute position [mm].""" + loc = (await self.request_gripper_location()).location + await self.move_relative_y(step_size=y - loc.y, allow_splitting=True) + + async def move_z(self, z: float) -> None: + """Move iSWAP Z to an absolute position [mm].""" + loc = (await self.request_gripper_location()).location + await self.move_relative_z(step_size=z - loc.z, allow_splitting=True) + + # -- rotation / wrist drive ------------------------------------------------ + + async def request_rotation_drive_position_increments(self) -> int: + """Query the iSWAP rotation drive position in increments (R0 RW).""" + response = await self.driver.send_command(module="R0", command="RW", fmt="rw######") + return cast(int, response["rw"]) + + async def request_rotation_drive_orientation(self) -> "iSWAPBackend.RotationDriveOrientation": + """Request the iSWAP rotation drive orientation. + + Uses empirically determined increment values: + FRONT: -25 +/- 50, RIGHT: +29068 +/- 50, LEFT: -29116 +/- 50 + """ + RDO = iSWAPBackend.RotationDriveOrientation + rotation_orientation_to_motor_increment_dict = { + RDO.FRONT: range(-75, 26), + RDO.RIGHT: range(29018, 29119), + RDO.LEFT: range(-29166, -29065), + RDO.PARKED_RIGHT: range(29450, 29550), + } + + motor_position_increments = await self.request_rotation_drive_position_increments() + + for orientation, increment_range in rotation_orientation_to_motor_increment_dict.items(): + if motor_position_increments in increment_range: + return orientation + + raise ValueError( + f"Unknown rotation orientation: {motor_position_increments}. " + f"Expected one of {list(rotation_orientation_to_motor_increment_dict.values())}." + ) + + async def request_wrist_drive_position_increments(self) -> int: + """Query the iSWAP wrist drive position in increments (R0 RT).""" + response = await self.driver.send_command(module="R0", command="RT", fmt="rt######") + return cast(int, response["rt"]) + + async def request_wrist_drive_orientation(self) -> "iSWAPBackend.WristDriveOrientation": + """Request the iSWAP wrist drive orientation. + + The wrist orientation is relative to the rotation drive orientation. + """ + WDO = iSWAPBackend.WristDriveOrientation + wrist_orientation_to_motor_increment_dict = { + WDO.RIGHT: range(-26_627, -26_527), + WDO.STRAIGHT: range(-8_804, -8_704), + WDO.LEFT: range(9_051, 9_151), + WDO.REVERSE: range(26_802, 26_902), + } + + motor_position_increments = await self.request_wrist_drive_position_increments() + + for orientation, increment_range in wrist_orientation_to_motor_increment_dict.items(): + if motor_position_increments in increment_range: + return orientation + + raise ValueError( + f"Unknown wrist orientation: {motor_position_increments}. " + f"Expected one of {list(wrist_orientation_to_motor_increment_dict)}." + ) + + async def rotate( + self, + rotation_drive: "iSWAPBackend.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, + ) -> None: + """Rotate the iSWAP to a predefined position (R0 PD). + + Velocity units are incr/sec. Acceleration units are 1000 incr/sec^2. + """ + if not 20 <= gripper_velocity <= 75_000: + raise ValueError("gripper_velocity must be between 20 and 75000") + if not 5 <= gripper_acceleration <= 200: + raise ValueError("gripper_acceleration must be between 5 and 200") + if not 20 <= wrist_velocity <= 65_000: + raise ValueError("wrist_velocity must be between 20 and 65000") + if not 20 <= wrist_acceleration <= 200: + raise ValueError("wrist_acceleration must be between 20 and 200") + + RDO = iSWAPBackend.RotationDriveOrientation + position = 0 + + if rotation_drive.value == RDO.LEFT.value: + position += 10 + elif rotation_drive.value == RDO.FRONT.value: + position += 20 + elif rotation_drive.value == RDO.RIGHT.value: + position += 30 + else: + raise ValueError(f"Invalid rotation drive orientation: {rotation_drive}") + + if grip_direction.value == GripDirection.FRONT.value: + position += 1 + elif grip_direction.value == GripDirection.RIGHT.value: + position += 2 + elif grip_direction.value == GripDirection.BACK.value: + position += 3 + elif grip_direction.value == GripDirection.LEFT.value: + position += 4 + else: + raise ValueError("Invalid grip direction") + + await self.driver.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 rotate_rotation_drive( + self, orientation: "iSWAPBackend.RotationDriveOrientation" + ) -> None: + """Rotate the rotation drive to the given orientation (R0 WP).""" + RDO = iSWAPBackend.RotationDriveOrientation + if orientation.value not in {RDO.RIGHT.value, RDO.FRONT.value, RDO.LEFT.value}: + raise ValueError(f"Invalid rotation drive orientation: {orientation}") + await self.driver.send_command( + module="R0", + command="WP", + auto_id=False, + wp=orientation.value, + ) + + async def rotate_wrist(self, orientation: "iSWAPBackend.WristDriveOrientation") -> None: + """Rotate the wrist to the given orientation (R0 TP).""" + await self.driver.send_command( + module="R0", + command="TP", + auto_id=False, + tp=orientation.value, + ) + + # -- collapse, teaching, velocity control ---------------------------------- + + async def collapse_gripper_arm( + self, + minimum_traverse_height: float = 360.0, + fold_up_at_end: bool = False, + ) -> None: + """Collapse / fold the gripper arm (C0 PN). + + Args: + minimum_traverse_height: Minimum traverse height [mm]. 0..360. + fold_up_at_end: Fold-up sequence at end of process. + """ + if not 0 <= minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + + await self.driver.send_command( + module="C0", + command="PN", + th=round(minimum_traverse_height * 10), + gc=fold_up_at_end, + ) + + async def prepare_teaching( + self, + x_position: float = 0, + x_direction: int = 0, + y_position: float = 0, + y_direction: int = 0, + z_position: float = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: float = 130.0, + grip_direction: int = 1, + minimum_traverse_height: float = 360.0, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ) -> None: + """Prepare for teaching with iSWAP (C0 PT). + + All position args are in mm. + """ + if not 0 <= x_position <= 3000: + raise ValueError("x_position must be between 0 and 3000") + if not 0 <= x_direction <= 1: + raise ValueError("x_direction must be between 0 and 1") + if not 0 <= y_position <= 650: + raise ValueError("y_position must be between 0 and 650") + if not 0 <= y_direction <= 1: + raise ValueError("y_direction must be between 0 and 1") + if not 0 <= z_position <= 360: + raise ValueError("z_position must be between 0 and 360") + if not 0 <= z_direction <= 1: + raise ValueError("z_direction must be between 0 and 1") + if not 0 <= location <= 1: + raise ValueError("location must be between 0 and 1") + if not 0 <= hotel_depth <= 300: + raise ValueError("hotel_depth must be between 0 and 300") + if not 0 <= minimum_traverse_height <= 360: + raise ValueError("minimum_traverse_height must be between 0 and 360") + if not 0 <= collision_control_level <= 1: + raise ValueError("collision_control_level must be between 0 and 1") + if not 0 <= acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + await self.driver.send_command( + module="C0", + command="PT", + xs=f"{round(x_position * 10):05}", + xd=x_direction, + yj=f"{round(y_position * 10):04}", + yd=y_direction, + zj=f"{round(z_position * 10):04}", + zd=z_direction, + hh=location, + hd=f"{round(hotel_depth * 10):04}", + gr=grip_direction, + th=f"{round(minimum_traverse_height * 10):04}", + ga=collision_control_level, + xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + ) + + async def get_logic_position( + self, + x_position: float = 0, + x_direction: int = 0, + y_position: float = 0, + y_direction: int = 0, + z_position: float = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: float = 130.0, + grip_direction: int = 1, + collision_control_level: int = 1, + ) -> None: + """Get logic iSWAP position (C0 PC). + + All position args are in mm. + """ + if not 0 <= x_position <= 3000: + raise ValueError("x_position must be between 0 and 3000") + if not 0 <= x_direction <= 1: + raise ValueError("x_direction must be between 0 and 1") + if not 0 <= y_position <= 650: + raise ValueError("y_position must be between 0 and 650") + if not 0 <= y_direction <= 1: + raise ValueError("y_direction must be between 0 and 1") + if not 0 <= z_position <= 360: + raise ValueError("z_position must be between 0 and 360") + if not 0 <= z_direction <= 1: + raise ValueError("z_direction must be between 0 and 1") + if not 0 <= location <= 1: + raise ValueError("location must be between 0 and 1") + if not 0 <= hotel_depth <= 300: + raise ValueError("hotel_depth must be between 0 and 300") + if not 1 <= grip_direction <= 4: + raise ValueError("grip_direction must be between 1 and 4") + if not 0 <= collision_control_level <= 1: + raise ValueError("collision_control_level must be between 0 and 1") + + await self.driver.send_command( + module="C0", + command="PC", + xs=round(x_position * 10), + xd=x_direction, + yj=round(y_position * 10), + yd=y_direction, + zj=round(z_position * 10), + zd=z_direction, + hh=location, + hd=round(hotel_depth * 10), + gr=grip_direction, + ga=collision_control_level, + ) + + # -- R0 parameter helpers (private) ---------------------------------------- + + async def _get_r0_parameter(self, name: str, fmt: str): + """Read a single R0 parameter via RA command.""" + return (await self.driver.send_command("R0", "RA", ra=name, fmt=fmt))[name] + + async def _set_r0_parameter(self, **kwargs) -> None: + """Set R0 parameter(s) via AA command.""" + await self.driver.send_command("R0", "AA", **kwargs) + + @asynccontextmanager + async def slow(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): + """Context manager that temporarily slows iSWAP wrist and gripper velocities (R0 RA/AA). + + Args: + wrist_velocity: Wrist velocity in incr/sec (20..65000). + gripper_velocity: Gripper velocity in incr/sec (20..75000). + """ + if not 20 <= gripper_velocity <= 75_000: + raise ValueError("gripper_velocity must be between 20 and 75000") + if not 20 <= wrist_velocity <= 65_000: + raise ValueError("wrist_velocity must be between 20 and 65000") + + original_wv = await self._get_r0_parameter("wv", "wv#####") + original_tv = await self._get_r0_parameter("tv", "tv#####") + + await self._set_r0_parameter(wv=gripper_velocity) + await self._set_r0_parameter(tv=wrist_velocity) + try: + yield + finally: + await self._set_r0_parameter(wv=original_wv) + await self._set_r0_parameter(tv=original_tv) + + @dataclass + class ParkParams(BackendParams): + """Parameters for parking the iSWAP arm. + + Args: + minimum_traverse_height: Minimum Z clearance in mm before lateral movement to + the park position. If None, uses the backend's ``traversal_height``. Must be + between 0 and 360.0. + """ + + minimum_traverse_height: Optional[float] = None + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the iSWAP. + + Args: + backend_params: iSWAP.ParkParams with minimum_traverse_height. + If None or not provided, uses ``self.traversal_height``. + """ + if not isinstance(backend_params, iSWAPBackend.ParkParams): + backend_params = iSWAPBackend.ParkParams() + + height = backend_params.minimum_traverse_height + if height is None: + height = self.traversal_height + + if not 0 <= height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + + await self.driver.send_command( + module="C0", + command="PG", + th=round(height * 10), + ) + self._parked = True + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the iSWAP gripper. + + Args: + gripper_width: Open position [mm]. + backend_params: Unused, reserved for future use. + """ + if not 0 <= gripper_width <= 999.9: + raise ValueError("gripper_width must be between 0 and 999.9") + + await self.driver.send_command(module="C0", command="GF", go=f"{round(gripper_width * 10):04}") + + @dataclass + class CloseGripperParams(BackendParams): + """Parameters for closing the iSWAP gripper. + + The gripper should be at position plate_width + plate_width_tolerance + 2.0 mm + before sending this command. + + Args: + grip_strength: Grip strength (0 = low, 9 = high). Must be between 0 and 9. + Default 5. + plate_width_tolerance: Plate width tolerance in mm. Must be between 0.5 and 9.9. + Default 2.0. + """ + + grip_strength: int = 5 + plate_width_tolerance: float = 2.0 + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Close the iSWAP gripper. + + Args: + gripper_width: Plate width [mm]. + backend_params: iSWAP.CloseGripperParams with grip_strength and plate_width_tolerance. + """ + if not isinstance(backend_params, iSWAPBackend.CloseGripperParams): + backend_params = iSWAPBackend.CloseGripperParams() + + if not 0 <= backend_params.grip_strength <= 9: + raise ValueError("grip_strength must be between 0 and 9") + if not 0 <= gripper_width <= 999.9: + raise ValueError("gripper_width must be between 0 and 999.9") + if not 0.5 <= backend_params.plate_width_tolerance <= 9.9: + raise ValueError("plate_width_tolerance must be between 0.5 and 9.9") + + await self.driver.send_command( + module="C0", + command="GC", + gw=backend_params.grip_strength, + gb=f"{round(gripper_width * 10):04}", + gt=f"{round(backend_params.plate_width_tolerance * 10):02}", + ) + + @dataclass + class PickUpParams(BackendParams): + """iSWAP-specific parameters for plate pickup. + + Args: + minimum_traverse_height: Minimum Z clearance in mm before lateral movement. + Must be between 0 and 360.0. Default 280.0. + z_position_at_end: Z position in mm at the end of the command. Must be between + 0 and 360.0. Default 280.0. + grip_strength: Grip strength (1 = low, 9 = high). Must be between 1 and 9. + Default 4. + plate_width_tolerance: Plate width tolerance in mm. Must be between 0 and 9.9. + Default 2.0. + collision_control_level: Collision control level (0 = low, 1 = high). Must be + 0 or 1. Default 0. + acceleration_index_high_acc: Acceleration index for high acceleration phases. + Must be between 0 and 4. Default 4. + acceleration_index_low_acc: Acceleration index for low acceleration phases. + Must be between 0 and 4. Default 1. + fold_up_at_end: Whether to fold up the iSWAP at the end of the process. + Default False. + """ + + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + grip_strength: int = 4 + plate_width_tolerance: float = 2.0 + collision_control_level: int = 0 + acceleration_index_high_acc: int = 4 + acceleration_index_low_acc: int = 1 + fold_up_at_end: bool = False + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center position [mm]. + direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). + resource_width: Plate width [mm]. + backend_params: iSWAP.PickUpParams for firmware-specific settings. + """ + if not isinstance(backend_params, iSWAPBackend.PickUpParams): + backend_params = iSWAPBackend.PickUpParams() + + open_gripper_position = resource_width + 3.0 + plate_width_for_firmware = round(resource_width * 10) - 33 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + if not 1 <= backend_params.grip_strength <= 9: + raise ValueError("grip_strength must be between 1 and 9") + if not 0 <= open_gripper_position <= 999.9: + raise ValueError("open_gripper_position must be between 0 and 999.9") + if not 0 <= resource_width <= 999.9: + raise ValueError("resource_width must be between 0 and 999.9") + if not 0 <= backend_params.plate_width_tolerance <= 9.9: + raise ValueError("plate_width_tolerance must be between 0 and 9.9") + if not 0 <= backend_params.collision_control_level <= 1: + raise ValueError("collision_control_level must be 0 or 1") + if not 0 <= backend_params.acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= backend_params.acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + grip_dir = _direction_degrees_to_grip_direction(direction) + + logger.info( + "[iSWAP] pick up plate: x=%.1f, y=%.1f, z=%.1f, direction=%.0f deg, width=%.1f mm", + location.x, + location.y, + location.z, + direction, + resource_width, + ) + + await self.driver.send_command( + module="C0", + command="PP", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yd=int(location.y < 0), + zj=f"{abs(round(location.z * 10)):04}", + zd=int(location.z < 0), + gr=grip_dir, + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + gw=backend_params.grip_strength, + go=f"{round(open_gripper_position * 10):04}", + gb=f"{plate_width_for_firmware:04}", + gt=f"{round(backend_params.plate_width_tolerance * 10):02}", + ga=backend_params.collision_control_level, + gc=backend_params.fold_up_at_end, + ) + self._parked = False + + @dataclass + class DropParams(BackendParams): + """iSWAP-specific parameters for plate drop. + + Args: + minimum_traverse_height: Minimum Z clearance in mm before lateral movement. + Must be between 0 and 360.0. Default 280.0. + z_position_at_end: Z position in mm at the end of the command. Must be between + 0 and 360.0. Default 280.0. + collision_control_level: Collision control level (0 = low, 1 = high). Must be + 0 or 1. Default 0. + acceleration_index_high_acc: Acceleration index for high acceleration phases. + Must be between 0 and 4. Default 4. + acceleration_index_low_acc: Acceleration index for low acceleration phases. + Must be between 0 and 4. Default 1. + fold_up_at_end: Whether to fold up the iSWAP at the end of the process. + Default False. + """ + + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + collision_control_level: int = 0 + acceleration_index_high_acc: int = 4 + acceleration_index_low_acc: int = 1 + fold_up_at_end: bool = False + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center position [mm]. + direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). + resource_width: Plate width [mm]. Used to compute open gripper position. + backend_params: iSWAP.DropParams for firmware-specific settings. + """ + if not isinstance(backend_params, iSWAPBackend.DropParams): + backend_params = iSWAPBackend.DropParams() + + open_gripper_position = resource_width + 3.0 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + if not 0 <= open_gripper_position <= 999.9: + raise ValueError("open_gripper_position must be between 0 and 999.9") + if not 0 <= backend_params.collision_control_level <= 1: + raise ValueError("collision_control_level must be 0 or 1") + if not 0 <= backend_params.acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= backend_params.acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + grip_dir = _direction_degrees_to_grip_direction(direction) + + logger.info( + "[iSWAP] release plate: x=%.1f, y=%.1f, z=%.1f, direction=%.0f deg, width=%.1f mm", + location.x, + location.y, + location.z, + direction, + resource_width, + ) + + await self.driver.send_command( + module="C0", + command="PR", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yd=int(location.y < 0), + zj=f"{abs(round(location.z * 10)):04}", + zd=int(location.z < 0), + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + gr=grip_dir, + go=f"{round(open_gripper_position * 10):04}", + ga=backend_params.collision_control_level, + gc=backend_params.fold_up_at_end, + ) + self._parked = False + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Check if the iSWAP is holding a plate. + + Returns: + True if holding a plate, False otherwise. + """ + resp = await self.driver.send_command(module="C0", command="QP", fmt="ph#") + return resp is not None and resp["ph"] == 1 + + @dataclass + class MoveToLocationParams(BackendParams): + """iSWAP-specific parameters for moving a held plate to a new position. + + Args: + minimum_traverse_height: Minimum Z clearance in mm before lateral movement. + Must be between 0 and 360.0. Default 360.0. + collision_control_level: Collision control level (0 = low, 1 = high). Must be + 0 or 1. Default 1. + acceleration_index_high_acc: Acceleration index for high acceleration phases. + Must be between 0 and 4. Default 4. + acceleration_index_low_acc: Acceleration index for low acceleration phases. + Must be between 0 and 4. Default 1. + """ + + minimum_traverse_height: float = 360.0 + collision_control_level: int = 1 + acceleration_index_high_acc: int = 4 + acceleration_index_low_acc: int = 1 + + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position [mm]. + direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). + backend_params: iSWAP.MoveToLocationParams for firmware-specific settings. + """ + if not isinstance(backend_params, iSWAPBackend.MoveToLocationParams): + backend_params = iSWAPBackend.MoveToLocationParams() + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.collision_control_level <= 1: + raise ValueError("collision_control_level must be 0 or 1") + if not 0 <= backend_params.acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= backend_params.acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + grip_dir = _direction_degrees_to_grip_direction(direction) + + logger.info( + "[iSWAP] move held plate to: x=%.1f, y=%.1f, z=%.1f, direction=%.0f deg", + location.x, + location.y, + location.z, + direction, + ) + + await self.driver.send_command( + module="C0", + command="PM", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yd=int(location.y < 0), + zj=f"{abs(round(location.z * 10)):04}", + zd=int(location.z < 0), + gr=grip_dir, + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + ga=backend_params.collision_control_level, + xe=f"{backend_params.acceleration_index_high_acc} {backend_params.acceleration_index_low_acc}", + ) + self._parked = False + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError("iSWAP halt not yet implemented") diff --git a/pylabrobot/hamilton/liquid_handlers/star/liquid_classes/__init__.py b/pylabrobot/hamilton/liquid_handlers/star/liquid_classes/__init__.py new file mode 100644 index 00000000000..c55461b5dc1 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/liquid_classes/__init__.py @@ -0,0 +1,6 @@ +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass +from pylabrobot.hamilton.liquid_handlers.star.liquid_classes.star_classes import ( + get_star_liquid_class, +) + +__all__ = ["HamiltonLiquidClass", "get_star_liquid_class"] diff --git a/pylabrobot/hamilton/liquid_handlers/star/liquid_classes/star_classes.py b/pylabrobot/hamilton/liquid_handlers/star/liquid_classes/star_classes.py new file mode 100644 index 00000000000..e0afe8ccb6d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/liquid_classes/star_classes.py @@ -0,0 +1,15005 @@ +from typing import Dict, Optional, Tuple + +from pylabrobot.hamilton.liquid_handlers.liquid_class 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/hamilton/liquid_handlers/star/misc/architecture.md b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md new file mode 100644 index 00000000000..375a8cf4405 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md @@ -0,0 +1,324 @@ +# STAR Architecture + +## Overview + +Split the monolithic `STARBackend` (~12k lines) into the new Driver + CapabilityBackend + Capability + Device architecture. + +**Migrated so far:** +- PIP and Head96 (capability backends) +- iSWAP and CoRe gripper (arm backends) +- AutoLoad, Cover, X-Arms, Wash Station (plain helper classes on the driver) +- Multi-channel PIP operations: channel positioning, initialization, foil piercing (on STARPIPBackend) +- ~44 generic driver infrastructure methods (firmware queries, EEPROM, area reservation, configuration) +- Channel minimum Y spacing query and enforcement + +## Layers + +``` +STAR (Device) — only exposes Capabilities + _driver ──────────► STARDriver (Driver) + │ │ Owns: USB I/O, firmware protocol, machine config + │ │ + │ ├─ pip: STARPIPBackend (PIPBackend) + │ ├─ head96: STARHead96Backend (Head96Backend) [optional] + │ ├─ iswap: iSWAP (OrientableGripperArmBackend) [optional] + │ ├─ autoload: STARAutoload [optional] + │ ├─ left_x_arm: STARXArm + │ ├─ right_x_arm: STARXArm + │ ├─ cover: STARCover + │ └─ wash_station: STARWashStation [optional] + │ + pip: PIP ──────────► pip backend (above) + head96: Head96 ────► head96 backend (above) + iswap: OrientableArm ► iswap backend (above) +``` + +The STAR device only exposes Capabilities (PIP, Head96, iSWAP). Subsystems (autoload, x-arms, cover, wash station) and generic driver methods live on `star._driver`. + +User code: + +```python +star = STAR() +await star.setup() + +# Capabilities — on the device +await star.pip.pick_up_tips(...) +await star.pip.aspirate(...) +await star.head96.aspirate96(...) +await star.iswap.move_resource(plate, destination) + +# Deck — on the device +star.deck.assign_child_resource(carrier, rails=3) + +# Subsystems — on the driver +await star._driver.autoload.load_carrier(carrier_end_rail=10) +await star._driver.left_x_arm.move_to(500.0) # mm +await star._driver.cover.lock() +await star._driver.wash_station.drain(station=1) + +# Generic driver methods +await star._driver.request_firmware_version() +await star._driver.halt() +``` + +## STARDriver + +Subclass of `HamiltonLiquidHandler` (which extends `HamiltonUSBDriver`, which extends `Driver`). Owns the USB connection and all firmware protocol logic. + +```python +class STARDriver(HamiltonLiquidHandler): + # Capability backends + pip: STARPIPBackend # always present + head96: Optional[STARHead96Backend] = None # if 96-head installed + iswap: Optional[iSWAP] = None # if iSWAP installed + + # Plain subsystems + autoload: Optional[STARAutoload] = None # if autoload installed + left_x_arm: Optional[STARXArm] = None # always present + right_x_arm: Optional[STARXArm] = None # if right X-drive installed + cover: Optional[STARCover] = None # always present + wash_station: Optional[STARWashStation] = None # if wash station installed +``` + +### Responsibilities + +- **USB I/O**: Connect/disconnect via `pylabrobot.io.usb.USB`. Background reading thread for async command/response matching. +- **Firmware protocol**: `send_command(module, command, **params)` assembles the STAR text protocol, sends it, waits for matching response, parses it. +- **Machine configuration**: On `setup()`, queries `RM` (machine config) and `QM` (extended config) to discover installed hardware. Stores as `self.machine_conf` and `self.extended_conf`. +- **Backend/subsystem creation**: During `setup()`, creates backends based on discovered config. Conditional for autoload (`auto_load_installed`), Head96 (`core_96_head_installed`), iSWAP (`iswap_installed`), right X-arm (`right_x_drive_large`), wash station (`wash_station_*_installed`). Unconditional for PIP, left X-arm, cover. +- **Channel spacing**: Queries per-channel minimum Y spacing from firmware during `setup()` and stores in `_channels_minimum_y_spacing`. Used by `_min_spacing_between()` for collision-safe channel positioning. +- **Generic instrument operations**: Firmware queries, EEPROM read/write, runtime control (halt, single-step), area reservation, instrument configuration. ~44 methods directly on the driver. +- **Tip type registration**: `_tth2tti` mapping shared across PIP and Head96. +- **Error parsing**: Firmware error codes → Python exceptions. + +### Generic driver methods (directly on STARDriver) + +These are machine-level operations not specific to any capability: + +- **Firmware queries**: `request_firmware_version`, `request_error_code`, `request_master_status`, `request_parameter_value`, `request_eeprom_data_correctness`, `request_electronic_board_type`, `request_supply_voltage`, `request_number_of_presence_sensors_installed` +- **Init/diagnostics**: `request_instrument_initialization_status`, `request_name_of_last_faulty_parameter`, `pre_initialize_instrument` +- **Runtime control**: `set_single_step_mode`, `trigger_next_step`, `halt`, `set_not_stop`, `save_all_cycle_counters` +- **EEPROM write**: `store_installation_data`, `store_verification_data`, `additional_time_stamp`, `save_download_date`, `save_technical_status_of_assemblies`, `set_x_offset_x_axis_*`, `save_pip_channel_validation_status`, `save_xl_channel_validation_status`, `configure_node_names`, `set_deck_data`, `set_instrument_configuration` +- **EEPROM read**: `request_technical_status_of_assemblies`, `request_installation_data`, `request_device_serial_number`, `request_download_date`, `request_verification_data`, `request_additional_timestamp_data`, `request_pip_channel_validation_status`, `request_xl_channel_validation_status`, `request_node_names`, `request_deck_data` +- **X-drive queries**: `request_maximal_ranges_of_x_drives`, `request_present_wrap_size_of_installed_arms` +- **Area reservation**: `occupy_and_provide_area_for_external_access`, `release_occupied_area`, `release_all_occupied_areas` + +## Plain subsystem classes + +These are NOT CapabilityBackends — they're plain helper classes that encapsulate firmware protocol for a subsystem and delegate I/O to the driver via `self._driver.send_command(...)`. + +### STARAutoload + +Controls the autoload module (carrier loading/unloading, barcode scanning, presence detection). + +```python +class STARAutoload: + def __init__(self, driver: STARDriver, instrument_size_slots: int = 54): + self._driver = driver + self._instrument_size_slots = instrument_size_slots +``` + +Key methods: `initialize`, `park`, `move_to_track`, `load_carrier`, `unload_carrier`, `request_presence_of_carriers_on_deck`, `request_presence_of_carriers_on_loading_tray`, `set_loading_indicators`, `verify_and_wait_for_carriers`, barcode operations. + +Methods take `carrier_end_rail: int` instead of `Carrier` objects — the caller computes the rail from carrier geometry. This keeps the class free of deck/resource dependencies. + +### STARXArm + +Controls one X-arm (left or right). One class, parameterized by side — picks the correct firmware command inline. + +```python +class STARXArm: + def __init__(self, driver: STARDriver, side: Literal["left", "right"]): + self._driver = driver + self._side = side +``` + +Methods: `move_to(x_position)` (mm), `move_to_safe(x_position)` (mm), `request_position() -> float` (mm), `last_collision_type() -> bool`. + +Command mapping: +| Operation | Left | Right | +|---|---|---| +| Position (collision risk) | C0:JX | C0:JS | +| Move safe (Z-safety) | C0:KX | C0:KR | +| Request position | C0:RX | C0:QX | +| Last collision type | C0:XX | C0:XR | + +### STARCover + +Controls the front cover. + +```python +class STARCover: + def __init__(self, driver: STARDriver): + self._driver = driver +``` + +Methods: `lock`, `unlock`, `disable`, `enable`, `is_open`, `set_output`, `reset_output`. + +### STARWashStation + +Controls dual-chamber wash/pump stations. + +```python +class STARWashStation: + def __init__(self, driver: STARDriver): + self._driver = driver +``` + +Methods: `request_settings(station)`, `initialize_valves(station)`, `fill_chamber(station, wash_fluid, chamber, ...)`, `drain(station)`. + +## Capability backends + +### STARPIPBackend + +Implements `PIPBackend`. Translates PIP operations into STAR firmware commands. + +Key methods: +- **Liquid handling**: `pick_up_tips`, `drop_tips`, `aspirate`, `dispense` +- **Channel positioning**: `position_channels_in_y_direction`, `position_channels_in_z_direction`, `get_channels_y_positions`, `get_channels_z_positions`, `move_all_pipetting_channels_to_defined_position`, `position_max_free_y_for_n`, `move_all_channels_in_z_safety`, `spread_pip_channels`, `move_channel_z` +- **Initialization**: `initialize_pip`, `initialize_pipetting_channels` +- **Foil operations**: `pierce_foil(deck=...)`, `step_off_foil(deck=...)` + +Methods that move channels in Y check `self._driver.iswap` and park it if needed. Parameters use mm (PLR standard); conversion to 0.1mm firmware units is done internally. + +### STARHead96Backend + +Implements `Head96Backend`. Translates 96-head operations into STAR firmware commands. + +Key methods: `pick_up_tips96`, `drop_tips96`, `aspirate96`, `dispense96`. + +### iSWAP + +Implements `OrientableGripperArmBackend`. Controls the iSWAP plate gripper arm. + +Key methods: `pick_up_at_location`, `drop_at_location`, `park`, `open_gripper`, `close_gripper`. + +### CoreGripper + +Implements `GripperArmBackend`. Uses two PIP channels as a Y-axis gripper. Managed through a context manager on the STAR device. + +```python +async with star.core_grippers(front_channel=7) as arm: + await arm.move_resource(plate, destination) +``` + +## STAR Device + +The user-facing class. Wires driver backends to capability frontends during `setup()`. + +```python +class STAR(Device): + def __init__(self, chatterbox: bool = False): + driver = STARChatterboxDriver() if chatterbox else STARDriver() + super().__init__(driver=driver) + self.deck = STARDeck() +``` + +### setup() flow + +``` +await star.setup() + │ + ├─ await STARDriver.setup() + │ 1. Open USB, start background reading thread + │ 2. Query RM → MachineConfiguration + │ 3. Query QM → ExtendedConfiguration + │ 4. Create backends: pip (always), head96 (if installed), iswap (if installed) + │ 5. Create subsystems: autoload (if installed), left x_arm, right x_arm (if installed), + │ cover, wash_station (if installed) + │ 6. Query per-channel minimum Y spacing + │ + ├─ Wire capability frontends to backends (on STAR device) + │ self.pip = PIP(backend=driver.pip) + │ self.head96 = Head96(backend=driver.head96) # if installed + │ self.iswap = OrientableArm(backend=driver.iswap) # if installed + │ + └─ Call _on_setup() for each Capability +``` + +Subsystems (autoload, x_arms, cover, wash_station) stay on the driver — the STAR device does NOT re-expose them. Access via `star._driver.autoload`, etc. + +### Optional hardware + +On the device: +```python +star.head96 # None if no 96-head +star.iswap # None if no iSWAP +``` + +On the driver: +```python +star._driver.autoload # None if no autoload module +star._driver.wash_station # None if no wash station +star._driver.left_x_arm # always present +star._driver.right_x_arm # None if no right X-drive +star._driver.cover # always present +``` + +## File structure + +``` +pylabrobot/hamilton/liquid_handlers/star/ + __init__.py # exports STAR, STARAutoload, STARCover, STARXArm, STARWashStation + star.py # STAR(Device) — only capabilities + driver.py # STARDriver + config dataclasses + generic driver methods + chatterbox.py # STARChatterboxDriver (mock for testing) + pip_backend.py # STARPIPBackend(PIPBackend) + head96_backend.py # STARHead96Backend(Head96Backend) + iswap.py # iSWAP(OrientableGripperArmBackend) + core.py # CoreGripper(GripperArmBackend) + autoload.py # STARAutoload (plain class on driver) + cover.py # STARCover (plain class on driver) + x_arm.py # STARXArm (plain class on driver) + wash_station.py # STARWashStation (plain class on driver) + tests/ + autoload_tests.py # 41 tests + cover_tests.py # 11 tests + x_arm_tests.py # 16 tests + wash_station_tests.py # 27 tests + iswap_tests.py # 14 tests + core_tests.py # 7 tests + legacy_parity_tests.py # PIP/Head96 parity tests +``` + +## Legacy compatibility + +The legacy `STARBackend` in `pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py` creates instances of the new classes in its `__init__`: + +```python +self._new_pip = STARPIPBackend(self) +self._new_head96 = STARHead96Backend(self) +self._new_autoload = STARAutoload(driver=self) +self._new_cover = STARCover(driver=self) +self._new_left_x_arm = STARXArm(driver=self, side="left") +self._new_right_x_arm = STARXArm(driver=self, side="right") +self._new_wash_station = STARWashStation(driver=self) + +# Public aliases so STARPIPBackend (which sees self as its driver) can access these. +self.left_x_arm = self._new_left_x_arm +self.iswap = None # legacy handles iSWAP parking via @need_iswap_parked decorator +``` + +Migrated methods delegate to these instances (with Carrier→int conversion where needed). All delegating methods have one-line deprecation docstrings: + +```python +async def park_autoload(self): + """Deprecated: use ``star.autoload.park()``.""" + return await self._new_autoload.park() + +async def pierce_foil(self, wells, ...): + """Deprecated: use ``star.pip.backend.pierce_foil()``.""" + await self._new_pip.pierce_foil(wells=wells, ..., deck=self.deck) +``` + +Generic driver methods (firmware queries, EEPROM, etc.) exist on both `STARDriver` and the legacy `STARBackend` — the legacy versions have deprecation docstrings but keep their original implementation bodies unchanged. + +The `left_x_arm` and `iswap` public aliases are needed because `STARPIPBackend` accesses `self._driver.left_x_arm` (for x-arm movement in `pierce_foil`) and `self._driver.iswap` (for iSWAP-parked checks). Since the legacy backend passes `self` as the driver to `STARPIPBackend`, these attributes must exist. `iswap = None` means the iSWAP park check safely no-ops on the legacy path (legacy handles it via its own `@need_iswap_parked` decorator). + +## What stays in legacy + +- Probing/LLD: `probe_liquid_heights`, CLLD/PLLD methods +- Hotel mode: `put_in_hotel`, `get_from_hotel` +- Heater-shaker: HHC temperature control methods +- Single-channel positioning: `move_channel_y`, `position_single_pipetting_channel_in_y/z_direction` +- Some lower-level PIP/Head96/iSWAP firmware commands not yet migrated to backends diff --git a/pylabrobot/hamilton/liquid_handlers/star/misc/core_test.ipynb b/pylabrobot/hamilton/liquid_handlers/star/misc/core_test.ipynb new file mode 100644 index 00000000000..5140927f426 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/core_test.ipynb @@ -0,0 +1,259 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# CoreGripper + Arm: Simple Resource Move Test\n", + "\n", + "Tests the `CoreGripper` backend through `Arm` with real PLR resources and the real STAR firmware interface.\n", + "\n", + "Tool management (pick up / return gripper tools) is still handled by the STAR 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.arms.arm import Arm\n", + "from pylabrobot.hamilton.liquid_handlers.star.core import CoreGripper\n", + "from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import STARBackend\n", + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "from pylabrobot.resources.hamilton.hamilton_decks import STARDeck\n", + "from pylabrobot.resources.hamilton.plate_carriers import PLT_CAR_L5AC_A00" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up deck with carrier and plate" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate 'my_plate' is in: carrier-1\n", + "Plate absolute location: Coordinate(396.500, 167.500, 183.120)\n", + "Destination site: carrier-2\n", + "Destination location: Coordinate(396.500, 263.500, 186.150)\n" + ] + } + ], + "source": [ + "deck = STARDeck(core_grippers=\"1000uL-at-waste\")\n", + "\n", + "carrier = PLT_CAR_L5AC_A00(\"carrier\")\n", + "deck.assign_child_resource(carrier, rails=14)\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n", + "carrier[1].assign_child_resource(plate)\n", + "\n", + "print(f\"Plate '{plate.name}' is in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")\n", + "print(f\"Destination site: {carrier[2].name}\")\n", + "print(f\"Destination location: {carrier[2].get_absolute_location()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create CoreGripper backend with real STAR interface" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-03-21 15:31:31,715 - pylabrobot.io.usb - INFO - Finding USB device...\n", + "2026-03-21 15:31:31,720 - pylabrobot.io.usb - INFO - Found USB device.\n", + "2026-03-21 15:31:31,720 - pylabrobot.io.usb - INFO - Found endpoints. \n", + "Write:\n", + " ENDPOINT 0x2: Bulk OUT ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x2 OUT\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0 \n", + "Read:\n", + " ENDPOINT 0x81: Bulk IN ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x81 IN\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Arm ready\n" + ] + } + ], + "source": [ + "star_backend = STARBackend()\n", + "star_backend.set_deck(deck)\n", + "await star_backend.setup()\n", + "\n", + "core_backend = CoreGripper(interface=star_backend)\n", + "\n", + "arm = Arm(backend=core_backend, reference_resource=deck, grip_axis=\"y\")\n", + "print(\"Arm ready\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick up core gripper tools\n", + "\n", + "Tool management is handled by the legacy STAR backend for now." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Core gripper tools picked up, core_parked: False\n" + ] + } + ], + "source": [ + "await star_backend.pick_up_core_gripper_tools(front_channel=7)\n", + "print(f\"Core gripper tools picked up, core_parked: {star_backend.core_parked}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick up plate from carrier[1]" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "arm._end_holding()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picked up 'my_plate'\n" + ] + } + ], + "source": [ + "await arm.pick_up_resource(plate)\n", + "print(f\"Picked up '{plate.name}'\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Drop plate at carrier[2]" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate 'my_plate' is now in: carrier-0\n", + "Plate absolute location: Coordinate(396.500, 071.500, 183.120)\n" + ] + } + ], + "source": [ + "await arm.drop_resource(carrier[0])\n", + "print(f\"Plate '{plate.name}' is now in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Return core gripper tools" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star_backend.return_core_gripper_tools()\n", + "print(f\"Core gripper tools returned, core_parked: {star_backend.core_parked}\")" + ] + } + ], + "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/pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb b/pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb new file mode 100644 index 00000000000..f8a4ee0c43e --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb @@ -0,0 +1,570 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "jxrwhpntxho", + "metadata": {}, + "source": [ + "# STAR Demo\n", + "\n", + "## Part 1: Legacy API\n", + "\n", + "Using `LiquidHandler` + `STARChatterboxBackend` to demonstrate PIP, Head96, iSWAP, and CoRe gripper." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5pqeiwi4f5b", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "id": "98oeafpq9ws", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "juixgcyrfkj", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.legacy.liquid_handling import LiquidHandler\n", + "from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_chatterbox import (\n", + " STARChatterboxBackend,\n", + ")\n", + "from pylabrobot.resources import (\n", + " PLT_CAR_L5AC_A00,\n", + " TIP_CAR_480_A00,\n", + " Cor_96_wellplate_360ul_Fb,\n", + " hamilton_96_tiprack_1000uL_filter,\n", + ")\n", + "from pylabrobot.resources.hamilton import STARDeck\n", + "\n", + "backend = STARChatterboxBackend()\n", + "deck = STARDeck()\n", + "lh = LiquidHandler(backend=backend, deck=deck)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "hu8hqlq3tia", + "metadata": {}, + "outputs": [], + "source": [ + "# Deck layout\n", + "tip_car = TIP_CAR_480_A00(name=\"tip_carrier\")\n", + "tip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\n", + "tip_car[1] = hamilton_96_tiprack_1000uL_filter(name=\"tips_02\")\n", + "deck.assign_child_resource(tip_car, rails=3)\n", + "\n", + "plt_car = PLT_CAR_L5AC_A00(name=\"plate_carrier\")\n", + "plt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\n", + "plt_car[1] = Cor_96_wellplate_360ul_Fb(name=\"plate_02\")\n", + "deck.assign_child_resource(plt_car, rails=15)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "beczhausu36", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "vr6w3hqe9x", + "metadata": {}, + "source": [ + "### PIP: independent channel pipetting" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "60qge4s6wzq", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0TTid0001tt01tf1tl0871tv10650tg3tu0\n", + "C0TPid0002xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tt01tp2266tz2166th2450td0\n", + "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&ms1000 1000 1000 1000&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&\n", + "C0DSid0004dm2 2 2 2&tm1 1 1 0&xp04333 04333 04333 00000&yp1187 1097 1007 0000&zx1866 1866 1866 1866&lp2000 2000 2000 2000&zl1866 1866 1866 1866&po0100 0100 0100 0100&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&zu0032 0032 0032 0032&zr06180 06180 06180 06180&th2450te2450dv01083 00563 02110 01083&ds1200 1200 1200 1200&ss0050 0050 0050 0050&rv000 000 000 000&ta300 300 300 300&ba0000 0000 0000 0000&lm0 0 0 0&dj00zo000 000 000 000&ll1 1 1 1&lv1 1 1 1&de0020 0020 0020 0020&wt10 10 10 10&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms0010 0010 0010 0010&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0\n", + "C0TRid0005xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tp2266tz2186th2450te2450ti1\n" + ] + } + ], + "source": [ + "tiprack = deck.get_resource(\"tips_01\")\n", + "plate = deck.get_resource(\"plate_01\")\n", + "\n", + "# Pick up tips on channels 0-2\n", + "await lh.pick_up_tips(tiprack[\"A1:C1\"])\n", + "\n", + "# Aspirate from wells A1-C1\n", + "await lh.aspirate(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])\n", + "\n", + "# Dispense into wells D1-F1\n", + "await lh.dispense(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])\n", + "\n", + "# Return tips\n", + "await lh.drop_tips(tiprack[\"A1:C1\"])" + ] + }, + { + "cell_type": "markdown", + "id": "zoszhm7xvkb", + "metadata": {}, + "source": [ + "### Head96: 96-channel head" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "fnm3orv5vah", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H0DQid0006dq11281dv13500du00000dr900000dw15\n", + "C0EPid0007xs01629xd0yh2418tt01wu0za2166zh2450ze2450\n", + "C0EAid0008aa0xs04333xd0yh1457zh2450ze2450lz1999zt1866pp0100zm1866zv0032zq06180iw000ix0fh000af00500ag2500vt050bv00000wv00050cm0cs1bs0020wh10hv00000hc00hp000mj000hs1200cwFFFFFFFFFFFFFFFFFFFFFFFFcr000cj0cx0\n", + "C0EDid0009da2xs04333xd0yh1457zm1866zv0032zq06180lz1999zt1866pp0100iw000ix0fh000zh2450ze2450df00500dg1200es0050ev000vt050bv00000cm0cs1ej00bs0020wh50hv00000hc00hp000mj000hs1200cwFFFFFFFFFFFFFFFFFFFFFFFFcr000cj0cx0\n", + "C0ERid0010xs01629xd0yh2418za2164zh2450ze2450\n" + ] + } + ], + "source": [ + "tiprack96 = deck.get_resource(\"tips_02\")\n", + "\n", + "await lh.pick_up_tips96(tiprack96)\n", + "await lh.aspirate96(plate, volume=50)\n", + "await lh.dispense96(plate, volume=50)\n", + "await lh.drop_tips96(tiprack96)" + ] + }, + { + "cell_type": "markdown", + "id": "xlfogucr4i", + "metadata": {}, + "source": [ + "### iSWAP: plate gripper arm" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "abkpgbe5u1t", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PPid0011xs04829xd0yj1142yd0zj1841zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "C0PRid0012xs04829xd0yj3062yd0zj1841zd0th2800te2800gr1go1308ga0gc0\n" + ] + } + ], + "source": [ + "# Move plate_01 from carrier slot 0 to slot 1 using iSWAP\n", + "await lh.move_resource(plate, plt_car[2], pickup_distance_from_top=13.2)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "qfpdyxxfkr", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PPid0013xs04829xd0yj3062yd0zj1841zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "C0PRid0014xs04829xd0yj1142yd0zj1841zd0th2800te2800gr1go1308ga0gc0\n" + ] + } + ], + "source": [ + "# Move it back\n", + "await lh.move_resource(plate, plt_car[0], pickup_distance_from_top=13.2)" + ] + }, + { + "cell_type": "markdown", + "id": "w0dhs17knr", + "metadata": {}, + "source": [ + "### CoRe gripper: channel-mounted plate gripper" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "i8sjlvr0k0i", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PGid0015th2800\n", + "C0ZPid0016xs04829xd0yj2102yv0050zj1841zy0500yo0885yg0825yw15th2800te2800\n", + "C0ZRid0017xs04829xd0yj3062zj1841zi000zy0500yo0885th2800te2800\n", + "C0ZSid0018xs13375xd0ya1250yb1070tp2150tz2050th2800te2800\n" + ] + } + ], + "source": [ + "plate2 = deck.get_resource(\"plate_02\")\n", + "\n", + "# Move plate_02 using CoRe gripper (channels 7+8)\n", + "await lh.move_resource(\n", + " plate2,\n", + " plt_car[2],\n", + " pickup_distance_from_top=13.2,\n", + " use_arm=\"core\",\n", + " core_front_channel=7,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bpmwb8wj95u", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0ZTid0019xs13375xd0ya1250yb1070pa07pb08tp2350tz2250th2800tt14\n", + "C0ZPid0020xs04829xd0yj3062yv0050zj1841zy0500yo0885yg0825yw15th2800te2800\n", + "C0ZRid0021xs04829xd0yj2102zj1841zi000zy0500yo0885th2800te2800\n", + "C0ZSid0022xs13375xd0ya1250yb1070tp2150tz2050th2800te2800\n" + ] + } + ], + "source": [ + "# Move it back\n", + "await lh.move_resource(\n", + " plate2,\n", + " plt_car[1],\n", + " pickup_distance_from_top=13.2,\n", + " use_arm=\"core\",\n", + " core_front_channel=7,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "dddt87cvc9u", + "metadata": {}, + "source": [ + "### Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ks7rs90y9f", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "dqbqe4pi3f4", + "metadata": {}, + "source": [ + "## Part 2: New API\n", + "\n", + "Using `STAR` device + `STARChatterboxDriver` with the new capability architecture." + ] + }, + { + "cell_type": "markdown", + "id": "irhljy3gm", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gnoke5r5gpa", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.hamilton.liquid_handlers.star.star import STAR\nfrom pylabrobot.resources import (\n PLT_CAR_L5AC_A00,\n TIP_CAR_480_A00,\n Cor_96_wellplate_360ul_Fb,\n hamilton_96_tiprack_1000uL_filter,\n)\n\nstar = STAR(chatterbox=True)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ubkc30t4fx", + "metadata": {}, + "outputs": [], + "source": "# Deck layout\ntip_car2 = TIP_CAR_480_A00(name=\"tip_carrier_2\")\ntip_car2[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_2_01\")\ntip_car2[1] = hamilton_96_tiprack_1000uL_filter(name=\"tips_2_02\")\nstar.deck.assign_child_resource(tip_car2, rails=3)\n\nplt_car2 = PLT_CAR_L5AC_A00(name=\"plate_carrier_2\")\nplt_car2[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_2_01\")\nplt_car2[1] = Cor_96_wellplate_360ul_Fb(name=\"plate_2_02\")\nstar.deck.assign_child_resource(plt_car2, rails=15)" + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "z7mk4dfm409", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pip: 8 channels\n", + "head96: True\n", + "iswap: True\n" + ] + } + ], + "source": [ + "await star.setup()\n", + "\n", + "print(f\"pip: {star.pip.num_channels} channels\")\n", + "print(f\"head96: {star.head96 is not None}\")\n", + "print(f\"iswap: {star.iswap is not None}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "c7fa139d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[,\n", + " ,\n", + " ]" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "star._capabilities" + ] + }, + { + "cell_type": "markdown", + "id": "s6bi76rsp1", + "metadata": {}, + "source": [ + "### PIP: independent channel pipetting\n", + "\n", + "TODO: implement `STARPIPBackend.pick_up_tips`, `aspirate`, `dispense`, `drop_tips`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "iuekwcjpdif", + "metadata": {}, + "outputs": [], + "source": "tiprack = star.deck.get_resource(\"tips_2_01\")\nplate = star.deck.get_resource(\"plate_2_01\")\n\nawait star.pip.pick_up_tips(tiprack[\"A1:C1\"])\nawait star.pip.aspirate(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])\nawait star.pip.dispense(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])\nawait star.pip.drop_tips(tiprack[\"A1:C1\"])" + }, + { + "cell_type": "markdown", + "id": "xg95f0pzo5h", + "metadata": {}, + "source": [ + "### Head96: 96-channel head\n", + "\n", + "TODO: implement `STARHead96Backend.pick_up_tips96`, `aspirate96`, `dispense96`, `drop_tips96`" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "5a33abcd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "star.head96.backend is star._driver.head96" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "samnutfxon", + "metadata": {}, + "outputs": [], + "source": "tiprack96 = star.deck.get_resource(\"tips_2_02\")\n\nawait star.head96.pick_up_tips(tiprack96)\nawait star.head96.aspirate(plate, volume=50)\nawait star.head96.dispense(plate, volume=50)\nawait star.head96.drop_tips(tiprack96)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "119db8a2", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "1he3sa6jysm", + "metadata": {}, + "source": [ + "### iSWAP: plate gripper arm\n", + "\n", + "The iSWAP backend is already implemented. The `OrientableArm` frontend provides `pick_up_resource` / `drop_resource` / `move_resource`." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "2f5285ac", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "star.iswap.backend is star._driver.iswap" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7zd9oqj09e7", + "metadata": {}, + "outputs": [], + "source": "plate2 = star.deck.get_resource(\"plate_2_01\")\n\n# Move plate between carrier slots using iSWAP\nawait star.iswap.move_resource(plate2, plt_car2[2], pickup_distance_from_bottom=13.2)" + }, + { + "cell_type": "markdown", + "id": "6e2ed94e", + "metadata": {}, + "source": [ + "Moving using a function that takes any `OrientableArm`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5b972f7", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.arms.orientable_arm import OrientableArm\n\n\nasync def move_resource(resource, destination, arm: OrientableArm):\n await arm.move_resource(resource, destination, pickup_distance_from_bottom=13.2)\n\n\nawait move_resource(plate2, plt_car2[0], arm=star.iswap)\n# await move_resource(plate2, plt_car2[0], arm=pf400.arm)" + }, + { + "cell_type": "markdown", + "id": "ukuz7tzhblk", + "metadata": {}, + "source": [ + "### CoRe gripper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa9e7f07", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PGid0015th2800\n", + "C0ZTid0016xs13375xd0ya1250yb1070pa07pb08tp2350tz2250th2800tt14\n", + "arm type: \n", + "C0ZPid0017xs04829xd0yj1142yv0050zj1841zy0500yo0885yg0825yw15th2800te2800\n", + "C0ZRid0018xs04829xd0yj3062zj1841zi000zy0500yo0885th2800te2800\n", + "C0ZSid0019xs13375xd0ya1250yb1070tp2150tz2050th2800te2800\n" + ] + } + ], + "source": [ + "async with star.core_grippers(front_channel=7) as arm:\n", + " await move_resource(plate2, plt_car2[2], arm=arm)" + ] + }, + { + "cell_type": "markdown", + "id": "w7o5tu6h8sd", + "metadata": {}, + "source": [ + "### Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "9fzkiqj66wa", + "metadata": {}, + "outputs": [], + "source": [ + "# await star.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.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/pylabrobot/hamilton/liquid_handlers/star/misc/iswap_test.ipynb b/pylabrobot/hamilton/liquid_handlers/star/misc/iswap_test.ipynb new file mode 100644 index 00000000000..c540015a33a --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/iswap_test.ipynb @@ -0,0 +1,292 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# iSWAP + OrientableArm: Simple Resource Move Test\n", + "\n", + "Tests the `iSWAP` backend through `OrientableArm` with real PLR resources and the real STAR firmware interface." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.arms.orientable_arm import OrientableArm\n", + "from pylabrobot.arms.standard import GripDirection\n", + "from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAP\n", + "from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import STARBackend\n", + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "from pylabrobot.resources.hamilton.hamilton_decks import STARLetDeck\n", + "from pylabrobot.resources.hamilton.plate_carriers import PLT_CAR_L5AC_A00" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up deck with carrier and plate" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate 'my_plate' is in: carrier-1\n", + "Plate absolute location: Coordinate(396.500, 167.500, 183.120)\n", + "Destination site: carrier-2\n", + "Destination location: Coordinate(396.500, 263.500, 186.150)\n" + ] + } + ], + "source": [ + "deck = STARLetDeck()\n", + "\n", + "carrier = PLT_CAR_L5AC_A00(\"carrier\")\n", + "deck.assign_child_resource(carrier, rails=14)\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n", + "carrier[1].assign_child_resource(plate)\n", + "\n", + "print(f\"Plate '{plate.name}' is in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")\n", + "print(f\"Destination site: {carrier[2].name}\")\n", + "print(f\"Destination location: {carrier[2].get_absolute_location()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create iSWAP backend with real STAR interface" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-03-20 22:03:16,770 - pylabrobot.io.usb - INFO - Finding USB device...\n", + "2026-03-20 22:03:16,811 - pylabrobot.io.usb - INFO - Found USB device.\n", + "2026-03-20 22:03:16,817 - pylabrobot.io.usb - INFO - Found endpoints. \n", + "Write:\n", + " ENDPOINT 0x2: Bulk OUT ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x2 OUT\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0 \n", + "Read:\n", + " ENDPOINT 0x81: Bulk IN ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x81 IN\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0RMid0001\n", + "cmd C0QMid0002\n", + "cmd C0QWid0003\n", + "cmd C0ZAid0004\n", + "cmd C0EVid0005\n", + "cmd C0RTid0006\n", + "cmd I0QWid0007\n", + "cmd P1VYid0008\n", + "cmd P2VYid0009\n", + "cmd P3VYid0010\n", + "cmd P4VYid0011\n", + "cmd P5VYid0012\n", + "cmd P6VYid0013\n", + "cmd P7VYid0014\n", + "cmd P8VYid0015\n", + "cmd P9VYid0016\n", + "cmd PAVYid0017\n", + "cmd PBVYid0018\n", + "cmd PCVYid0019\n", + "cmd C0IVid0020\n", + "cmd R0QWid0021\n", + "cmd I0XPid0022xp54\n", + "cmd C0PGid0023th2800\n", + "cmd H0QWid0024\n", + "cmd H0RFid0025\n", + "cmd H0QUid0026\n", + "cmd H0QGid0027\n", + "OrientableArm ready, iSWAP parked: False\n" + ] + } + ], + "source": [ + "star_backend = STARBackend()\n", + "star_backend.set_deck(deck)\n", + "await star_backend.setup()\n", + "\n", + "iswap_backend = iSWAP(interface=star_backend)\n", + "\n", + "arm = OrientableArm(backend=iswap_backend, reference_resource=deck)\n", + "print(f\"OrientableArm ready, iSWAP parked: {iswap_backend.parked}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick up plate from carrier[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0PPid0030xs04604xd0yj2102yd0zj1923zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "Picked up 'my_plate'\n" + ] + } + ], + "source": [ + "await arm.pick_up_resource(\n", + " plate,\n", + " direction=GripDirection.FRONT,\n", + " backend_params=iSWAP.PickUpParams(\n", + " minimum_traverse_height=280.0,\n", + " z_position_at_end=280.0,\n", + " grip_strength=4,\n", + " plate_width_tolerance=2.0,\n", + " collision_control_level=0,\n", + " fold_up_at_end=False,\n", + " ),\n", + ")\n", + "print(f\"Picked up '{plate.name}'\")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0PGid0029th2840\n" + ] + } + ], + "source": [ + "await arm.park()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Drop plate at carrier[2]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0PRid0031xs04604xd0yj3062yd0zj1923zd0th2800te2800gr1go1308ga0gc0\n", + "Plate 'my_plate' is now in: carrier-2\n", + "Plate absolute location: Coordinate(396.500, 263.500, 183.120)\n" + ] + } + ], + "source": [ + "await arm.drop_resource(carrier[2], direction=GripDirection.FRONT)\n", + "print(f\"Plate '{plate.name}' is now in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.pick_up_resource(\n", + " plate,\n", + " direction=GripDirection.LEFT,\n", + ")\n", + "await arm.drop_resource(carrier[2], direction=GripDirection.RIGHT)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Park and check state" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.park()\n", + "print(f\"iSWAP parked: {iswap_backend.parked}\")" + ] + } + ], + "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 +} \ No newline at end of file diff --git a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py new file mode 100644 index 00000000000..cb69be087eb --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -0,0 +1,1956 @@ +"""STAR PIP backend: translates PIP operations into STAR firmware commands.""" + +from __future__ import annotations + +import asyncio +import enum +import logging +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Sequence, Tuple, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.capabilities.liquid_handling.utils import ( + get_tight_single_resource_liquid_op_offsets, + get_wide_single_resource_liquid_op_offsets, +) +from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import ( + HamiltonLiquidClass, + get_star_liquid_class, +) +from pylabrobot.resources import Resource, Tip, TipSpot, Well +from pylabrobot.resources.hamilton import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize +from pylabrobot.resources.liquid import Liquid + +from .errors import ( + STARFirmwareError, + convert_star_firmware_error_to_plr_error, +) +from .pip_channel import PIPChannel + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Firmware command lock +# --------------------------------------------------------------------------- + + +class _FirmwareLock: + """Coordinates Px and C0 firmware commands. + + Px commands (per-channel) can run in parallel with each other. + C0 commands (master module) need exclusive access: no Px or C0 may be in flight. + """ + + def __init__(self): + self._px_count = 0 + self._px_count_lock = asyncio.Lock() + self._exclusive_lock = asyncio.Lock() + + @asynccontextmanager + async def px(self): + """Run a Px command. Multiple Px can be in flight simultaneously.""" + async with self._px_count_lock: + self._px_count += 1 + if self._px_count == 1: + await self._exclusive_lock.acquire() + try: + yield + finally: + async with self._px_count_lock: + self._px_count -= 1 + if self._px_count == 0: + self._exclusive_lock.release() + + @asynccontextmanager + async def c0(self): + """Run a C0 command. Waits for all Px to finish, then runs exclusively.""" + async with self._exclusive_lock: + yield + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ops_to_fw_positions( + ops: Sequence[Union[Pickup, TipDrop, Aspiration, Dispense]], + use_channels: List[int], + num_channels: int, +) -> Tuple[List[int], List[int], List[bool]]: + """Convert ops + use_channels into firmware x/y positions and tip pattern. + + Uses absolute coordinates (get_absolute_location) so the driver does not + need a ``deck`` reference. This mirrors ``HamiltonLiquidHandler._ops_to_fw_positions`` + but is self-contained. + """ + if use_channels != sorted(use_channels): + raise ValueError("Channels must be sorted.") + + x_positions: List[int] = [] + y_positions: List[int] = [] + channels_involved: List[bool] = [] + + for i, channel in enumerate(use_channels): + # Pad unused channels with zeros. + while channel > len(channels_involved): + channels_involved.append(False) + x_positions.append(0) + y_positions.append(0) + channels_involved.append(True) + + loc = ops[i].resource.get_absolute_location(x="c", y="c", z="b") + x_positions.append(round((loc.x + ops[i].offset.x) * 10)) + y_positions.append(round((loc.y + ops[i].offset.y) * 10)) + + # Minimum distance check (9mm per channel index difference). + for idx1, (x1, y1) in enumerate(zip(x_positions, y_positions)): + for idx2, (x2, y2) in enumerate(zip(x_positions, y_positions)): + if idx1 == idx2: + continue + if not channels_involved[idx1] or not channels_involved[idx2]: + continue + if x1 != x2: + continue + if y1 != y2 and abs(y1 - y2) < 90: + raise ValueError( + f"Minimum distance between two y positions is <9mm: {y1}, {y2}" + f" (channel {idx1} and {idx2})" + ) + + if len(ops) > num_channels: + raise ValueError(f"Too many channels specified: {len(ops)} > {num_channels}") + + # Trailing padding (STAR firmware expects at least one extra slot when < num_channels). + if len(x_positions) < num_channels: + x_positions = x_positions + [0] + y_positions = y_positions + [0] + channels_involved = channels_involved + [False] + + return x_positions, y_positions, channels_involved + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class LLDMode(enum.Enum): + """Liquid level detection mode.""" + + OFF = 0 + GAMMA = 1 + PRESSURE = 2 + DUAL = 3 + Z_TOUCH_OFF = 4 + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + + +def _resolve_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + jet: Union[bool, List[bool]], + blow_out: Union[bool, List[bool]], + is_aspirate: bool, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, auto-detect from tip properties for each op. + If ``explicit`` is a list, use it as-is (None entries stay None, matching legacy behavior). + """ + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + return result + + +def _fill(val: Optional[List], default: List) -> List: + """Return *val* if given, otherwise *default*. Replace per-element None with default.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: + """Compute firmware dispensing mode from boolean flags. + + Firmware modes: + 0 = Partial volume in jet mode + 1 = Blow out in jet mode (labelled "empty" in VENUS) + 2 = Partial volume at surface + 3 = Blow out at surface (labelled "empty" in VENUS) + 4 = Empty tip at fix position + """ + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + return 3 if blow_out else 2 + + +# --------------------------------------------------------------------------- +# STARPIPBackend +# --------------------------------------------------------------------------- + + +def _assert_range(values, lo, hi, name): + """Assert all values in a list are within [lo, hi].""" + if not all(lo <= v <= hi for v in values): + raise ValueError(f"{name} values must be between {lo} and {hi}, got {values}") + + +class STARPIPBackend(PIPBackend): + """Translates PIP operations into STAR firmware commands via the driver.""" + + def __init__(self, driver: STARDriver, traversal_height: float = 245.0): + self.driver = driver + self.traversal_height = traversal_height + self.channels: List[PIPChannel] = [] + self._fw_lock = _FirmwareLock() + + async def send_command(self, module: str, command: str, **kwargs): + """Send a firmware command. C0 gets exclusive access; Px commands run in parallel.""" + if module == "C0": + async with self._fw_lock.c0(): + return await self.driver.send_command(module=module, command=command, **kwargs) + async with self._fw_lock.px(): + return await self.driver.send_command(module=module, command=command, **kwargs) + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + self.channels = [PIPChannel(self.driver, i, backend=self) for i in range(self.num_channels)] + + # Initialize PIP channels if the instrument was not yet initialized + # or if any channel still has a tip mounted (need to discard). + initialized = await self.driver.request_instrument_initialization_status() + if not initialized: + # pre_initialize_instrument already ran in driver.setup() and moved channels to Z safety + await self.initialize_pip() + else: + await self.move_all_channels_in_z_safety() + tip_presences = await self.request_tip_presence() + if any(tip_presences): + await self.initialize_pip() + + @contextmanager + def use_traversal_height(self, height: float): + """Temporarily override the traversal height for all PIP operations.""" + original = self.traversal_height + self.traversal_height = height + try: + yield + finally: + self.traversal_height = original + + @property + def num_channels(self) -> int: + return self.driver.num_channels + + def _ensure_can_reach_position( + self, + use_channels: List[int], + ops: Sequence[Union[Pickup, TipDrop, Aspiration, Dispense]], + op_name: str, + ) -> None: + """Validate that each channel can physically reach its target Y position.""" + if self.driver.extended_conf is None: + return # skip validation if config not available (e.g. chatterbox) + ext = self.driver.extended_conf + spacings = self.driver._channels_minimum_y_spacing + if not spacings: + spacings = [ext.min_raster_pitch_pip_channels] * self.num_channels + + cant_reach = [] + for channel_idx, op in zip(use_channels, ops): + loc = op.resource.get_absolute_location(x="c", y="c", z="b") + op.offset + min_y = ext.left_arm_min_y_position + sum(spacings[channel_idx + 1 :]) + max_y = ext.pip_maximal_y_position - sum(spacings[:channel_idx]) + if loc.y < min_y or loc.y > max_y: + cant_reach.append(channel_idx) + + if cant_reach: + 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." + ) + + # -- pick_up_tips ----------------------------------------------------------- + + @dataclass + class PickUpTipsParams(BackendParams): + """STAR-specific parameters for ``pick_up_tips``. + + Args: + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm before + lateral movement begins. Applies to all channels regardless of tip pattern. If None, + uses the backend's ``traversal_height``. Must be between 0 and 360.0. + pickup_method: Tip pickup strategy. If None, uses the default from the HamiltonTip + definition. + begin_tip_pick_up_process: Z position in mm to begin the tip pickup process (start of + Z descent). If None, computed from tip fitting depth + tip spot position. Must be + between 0 and 360.0. + end_tip_pick_up_process: Z position in mm to end the tip pickup process (final engage + depth). If None, computed from tip length + tip spot position. Must be between 0 + and 360.0. + """ + + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + pickup_method: Optional[TipPickupMethod] = None + begin_tip_pick_up_process: Optional[float] = None + end_tip_pick_up_process: Optional[float] = None + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.PickUpTipsParams): + backend_params = STARPIPBackend.PickUpTipsParams() + + await self.driver.ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "pick_up_tips") + + logger.info( + "[STAR PIP] pick_up_tips: resource=%s, channels=%s", + ops[0].resource.parent.name if ops[0].resource.parent else ops[0].resource.name, + use_channels, + ) + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + # Tip type registration. + tips = set() + for op in ops: + tip = op.tip + if not isinstance(tip, HamiltonTip): + raise TypeError(f"Tip {tip} is not a HamiltonTip.") + tips.add(tip) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + ham_tip = tips.pop() + if not isinstance(ham_tip, HamiltonTip): + raise TypeError(f"Expected HamiltonTip, got {type(ham_tip).__name__}") + ttti = await self.driver.request_or_assign_tip_type_index(ham_tip) + + # Z computations (absolute coordinates). + max_z = max( + op.resource.get_absolute_location(x="c", y="c", z="b").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) + + if ham_tip.tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif ham_tip.tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + begin_tip_pick_up_process = ( + round(backend_params.begin_tip_pick_up_process * 10) + if backend_params.begin_tip_pick_up_process is not None + else round((max_z + max_total_tip_length) * 10) + ) + end_tip_pick_up_process = ( + round(backend_params.end_tip_pick_up_process * 10) + if backend_params.end_tip_pick_up_process is not None + else round((max_z + max_tip_length) * 10) + ) + + minimum_traverse_height_at_beginning_of_a_command = round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or self.traversal_height) + * 10 + ) + + pickup_method = backend_params.pickup_method or ham_tip.pickup_method + + # Range validation (matches legacy pick_up_tip assertions). + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + if not 0 <= begin_tip_pick_up_process <= 3600: + raise ValueError("begin_tip_pick_up_process must be 0-3600") + if not 0 <= end_tip_pick_up_process <= 3600: + raise ValueError("end_tip_pick_up_process must be 0-3600") + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + + try: + await self.driver.send_command( + module="C0", + command="TP", + tip_pattern=channels_involved, + read_timeout=max(120, self.driver.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=channels_involved, + tt=f"{ttti: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, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- drop_tips -------------------------------------------------------------- + + @dataclass + class DropTipsParams(BackendParams): + """STAR-specific parameters for ``drop_tips``. + + When the drop method is ``PLACE_SHIFT``, the begin/end deposit positions refer to the + tip cone end height. Otherwise, they refer to the stop-disk height. + + Args: + drop_method: Tip discard strategy. If None, auto-selected: ``PLACE_SHIFT`` when + discarding into a non-TipSpot resource, ``DROP`` when returning to a TipSpot. + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm before + lateral movement begins. Applies to all channels regardless of tip pattern. If None, + uses the backend's ``traversal_height``. Must be between 0 and 360.0. + z_position_at_end_of_a_command: Z position in mm at the end of the command. If None, + uses the backend's ``traversal_height``. Must be between 0 and 360.0. + begin_tip_deposit_process: Z position in mm to begin the tip deposit process. + If None, computed from tip geometry and drop method. Must be between 0 and 360.0. + end_tip_deposit_process: Z position in mm to end the tip deposit process. + If None, computed from tip geometry and drop method. Must be between 0 and 360.0. + """ + + drop_method: Optional[TipDropMethod] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + z_position_at_end_of_a_command: Optional[float] = None + begin_tip_deposit_process: Optional[float] = None + end_tip_deposit_process: Optional[float] = None + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.DropTipsParams): + backend_params = STARPIPBackend.DropTipsParams() + + await self.driver.ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "drop_tips") + + logger.info( + "[STAR PIP] drop_tips: resource=%s, channels=%s", + ops[0].resource.parent.name if ops[0].resource.parent else ops[0].resource.name, + use_channels, + ) + + drop_method = backend_params.drop_method + 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 = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + max_z = max( + op.resource.get_absolute_location(x="c", y="c", z="b").z + op.offset.z for op in ops + ) + + if backend_params.begin_tip_deposit_process is not None: + begin_tip_deposit_process = round(backend_params.begin_tip_deposit_process * 10) + elif drop_method == TipDropMethod.PLACE_SHIFT: + begin_tip_deposit_process = round((max_z + 59.9) * 10) + else: + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + begin_tip_deposit_process = round((max_z + max_total_tip_length) * 10) + + if backend_params.end_tip_deposit_process is not None: + end_tip_deposit_process = round(backend_params.end_tip_deposit_process * 10) + elif drop_method == TipDropMethod.PLACE_SHIFT: + end_tip_deposit_process = round((max_z + 49.9) * 10) + else: + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + end_tip_deposit_process = round((max_z + max_tip_length) * 10) + + minimum_traverse_height_at_beginning_of_a_command = round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or self.traversal_height) + * 10 + ) + z_position_at_end_of_a_command = round( + (backend_params.z_position_at_end_of_a_command or self.traversal_height) * 10 + ) + + # Range validation (matches legacy discard_tip assertions). + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + if not 0 <= begin_tip_deposit_process <= 3600: + raise ValueError("begin_tip_deposit_process must be 0-3600") + if not 0 <= end_tip_deposit_process <= 3600: + raise ValueError("end_tip_deposit_process must be 0-3600") + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + if not 0 <= z_position_at_end_of_a_command <= 3600: + raise ValueError("z_position_at_end_of_a_command must be 0-3600") + + try: + await self.driver.send_command( + module="C0", + command="TR", + tip_pattern=channels_involved, + read_timeout=max(120, self.driver.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=channels_involved, + 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=drop_method.value, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- aspirate --------------------------------------------------------------- + + @dataclass + class AspirateParams(BackendParams): + """STAR-specific parameters for ``aspirate``. + + All per-channel list parameters accept ``None`` to use sensible defaults (typically + derived from liquid classes or container geometry). When provided, lists must have one + entry per channel involved in the operation. + + LLD restrictions: + - "dP and Dual LLD" are used in aspiration only. During dispensation, pressure-based + LLD is set to OFF. + - "side touch off" turns LLD and "Z touch off" to OFF and is not available for + simultaneous aspirate/dispense commands. + + Args: + hamilton_liquid_classes: Per-channel Hamilton liquid class overrides. If None, + auto-detected from tip type and liquid. + disable_volume_correction: Per-channel flag to disable liquid-class volume correction. + aspiration_type: Type of aspiration per channel (0 = simple, 1 = sequence, + 2 = cup emptied). Must be between 0 and 2. + jet: Per-channel flag used for liquid class selection (jet vs surface mode). + blow_out: Per-channel flag used for liquid class selection. + lld_search_height: LLD search height in mm (relative to well bottom). If None, + computed from container geometry. Must be between 0 and 360.0. + clot_detection_height: Check height of clot detection above the current liquid + surface in mm. If None, uses liquid class default. Must be between 0 and 50.0. + pull_out_distance_transport_air: Distance in mm to pull out for transport air when + not using LLD. Must be between 0 and 360.0. Default 10.0. + second_section_height: Tube 2nd section height measured from minimum_height in mm. + Must be between 0 and 360.0. Default 3.2. + second_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter. Must be between 0 and 1000.0. Default 618.0. + minimum_height: Minimum height (maximum immersion depth) in mm. If None, uses well + bottom. Must be between 0 and 360.0. + immersion_depth: Immersion depth in mm. Positive = go deeper into liquid, + negative = go up out of liquid. Must be between -360.0 and 360.0. + surface_following_distance: Surface following distance during aspiration in mm. + Must be between 0 and 360.0. + transport_air_volume: Transport air volume in uL. If None, uses liquid class + default. Must be between 0 and 50.0. + pre_wetting_volume: Pre-wetting volume in uL. Must be between 0 and 99.9. + lld_mode: LLD mode per channel (OFF, GAMMA, DP, DUAL, Z_TOUCH_OFF). Default OFF. + gamma_lld_sensitivity: Gamma LLD sensitivity per channel (1 = high, 4 = low). + Must be between 1 and 4. + dp_lld_sensitivity: Delta-P LLD sensitivity per channel (1 = high, 4 = low). + Must be between 1 and 4. + aspirate_position_above_z_touch_off: Aspirate position above Z touch off in mm. + Must be between 0 and 10.0. + detection_height_difference_for_dual_lld: Height difference for dual LLD detection + in mm. Must be between 0 and 9.9. + swap_speed: Swap speed (on leaving liquid) in mm/s. If None, uses liquid class + default. Must be between 0.3 and 160.0. + settling_time: Settling time in seconds. If None, uses liquid class default. + Must be between 0 and 9.9. + mix_position_from_liquid_surface: Mix position in Z direction from liquid surface + (LLD or absolute terms) in mm. Must be between 0 and 90.0. + mix_surface_following_distance: Surface following distance during mix in mm. + Must be between 0 and 360.0. + limit_curve_index: Limit curve index for TADM. Must be between 0 and 999. + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm before + lateral movement. If None, uses backend's ``traversal_height``. Must be between + 0 and 360.0. + min_z_endpos: Minimum Z position in mm at end of command. If None, uses backend's + ``traversal_height``. Must be between 0 and 360.0. + liquid_surface_no_lld: Absolute liquid surface position in mm when not using LLD. + If None, computed from well bottom + liquid height. + use_2nd_section_aspiration: Per-channel flag to enable 2nd section aspiration. + retract_height_over_2nd_section_to_empty_tip: Retract height over 2nd section to + empty tip in mm. Must be between 0 and 360.0. + dispensation_speed_during_emptying_tip: Dispensation speed during emptying tip in + uL/s. Must be between 0.4 and 500.0. + dosing_drive_speed_during_2nd_section_search: Dosing drive speed during 2nd section + search in uL/s. Must be between 0.4 and 500.0. + z_drive_speed_during_2nd_section_search: Z drive speed during 2nd section search in + mm/s. Must be between 0.3 and 160.0. + cup_upper_edge: Cup upper edge in mm. Must be between 0 and 360.0. + tadm_algorithm: Whether to use the TADM algorithm. Default False. + recording_mode: Recording mode (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Must be between 0 and 2. + probe_liquid_height: If True, use gamma LLD to probe the liquid height before + aspirating. Cannot be used when liquid heights are already set on operations. + auto_surface_following_distance: If True, automatically compute the surface + following distance from volume and container geometry. Requires liquid heights + to be set (or ``probe_liquid_height=True``) and containers with height/volume + conversion functions. + """ + + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + aspiration_type: Optional[List[int]] = None + 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 + 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 + 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 + tadm_algorithm: bool = False + recording_mode: int = 0 + probe_liquid_height: bool = False + auto_surface_following_distance: bool = False + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.AspirateParams): + backend_params = STARPIPBackend.AspirateParams() + + await self.driver.ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "aspirate") + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + n = len(ops) + + # Resolve liquid classes (auto-detect from tip if not provided). + hlcs = _resolve_liquid_classes( + backend_params.hamilton_liquid_classes, + ops, + jet=backend_params.jet or False, + blow_out=backend_params.blow_out or False, + is_aspirate=True, + ) + + # Well bottoms (absolute z + material thickness). + well_bottoms = [ + op.resource.get_absolute_location(x="c", y="c", z="b").z + + op.offset.z + + op.resource.material_z_thickness + for op in ops + ] + + # LLD search height. + if backend_params.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, backend_params.lld_search_height) + ] + + clot_detection_height = _fill( + backend_params.clot_detection_height, + [hlc.aspiration_clot_retract_height if hlc is not None else 0.0 for hlc in hlcs], + ) + pull_out_distance_transport_air = _fill( + backend_params.pull_out_distance_transport_air, [10.0] * n + ) + second_section_height = _fill(backend_params.second_section_height, [3.2] * n) + second_section_ratio = _fill(backend_params.second_section_ratio, [618.0] * n) + minimum_height = _fill(backend_params.minimum_height, well_bottoms) + + immersion_depth_raw = backend_params.immersion_depth or [0.0] * n + immersion_depth_direction = [0 if id_ >= 0 else 1 for id_ in immersion_depth_raw] + immersion_depth = [abs(im) for im in immersion_depth_raw] + + surface_following_distance = _fill(backend_params.surface_following_distance, [0.0] * n) + + # Volumes (with liquid class correction). + disable_vc = _fill(backend_params.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, hlcs, disable_vc) + ] + + # Flow rates (liquid class default). + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hlcs) + ] + + logger.info( + "[STAR PIP] aspirate: resource=%s, channels=%s, volumes=%s, flow_rates=%s", + ops[0].resource.parent.name if ops[0].resource.parent else ops[0].resource.name, + use_channels, + [round(v, 3) for v in volumes], + [round(fr, 3) for fr in flow_rates], + ) + + transport_air_volume = _fill( + backend_params.transport_air_volume, + [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + 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, hlcs) + ] + pre_wetting_volume = _fill(backend_params.pre_wetting_volume, [0.0] * n) + lld_mode = _fill(backend_params.lld_mode, [LLDMode.OFF] * n) + gamma_lld_sensitivity = _fill(backend_params.gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = _fill(backend_params.dp_lld_sensitivity, [1] * n) + aspirate_position_above_z_touch_off = _fill( + backend_params.aspirate_position_above_z_touch_off, [0.0] * n + ) + detection_height_difference_for_dual_lld = _fill( + backend_params.detection_height_difference_for_dual_lld, [0.0] * n + ) + swap_speed = _fill( + backend_params.swap_speed, + [hlc.aspiration_swap_speed if hlc is not None else 100.0 for hlc in hlcs], + ) + settling_time = _fill( + backend_params.settling_time, + [hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hlcs], + ) + + # Mix. + 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( + backend_params.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(backend_params.mix_surface_following_distance, [0.0] * n) + limit_curve_index = _fill(backend_params.limit_curve_index, [0] * n) + + # Probe liquid height if requested. + traverse_height_override = backend_params.minimum_traverse_height_at_beginning_of_a_command + if backend_params.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.driver.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + move_to_z_safety_after=False, + ) + logger.info("Detected liquid heights: %s", liquid_heights) + traverse_height_override = 100.0 + else: + liquid_heights = [op.liquid_height or 0.0 for op in ops] + + # Auto surface following distance. + if backend_params.auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not backend_params.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( + "auto_surface_following_distance requires containers with height<->volume functions." + ) + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + liquid_height_after = [ + op.resource.compute_height_from_volume(current_volumes[i] - op.volume) + for i, op in enumerate(ops) + ] + surface_following_distance = [liquid_heights[i] - liquid_height_after[i] for i in range(n)] + + liquid_surfaces_no_lld = backend_params.liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + # Check surface following distance doesn't go below minimum height (when LLD is off). + if any( + ( + well_bottoms[i] + liquid_heights[i] - surface_following_distance[i] - minimum_height[i] + < -1e-6 + ) + and lld_mode[i] == LLDMode.OFF + for i in range(n) + ): + raise ValueError( + f"surface_following_distance would result in a height below minimum_height. " + f"Well bottom: {well_bottoms}, liquid height: {liquid_heights}, " + f"surface_following_distance: {surface_following_distance}, minimum_height: {minimum_height}" + ) + + minimum_traverse_height_at_beginning_of_a_command = round( + (traverse_height_override or self.traversal_height) * 10 + ) + min_z_endpos = round((backend_params.min_z_endpos or self.traversal_height) * 10) + + # Range validation (matches legacy aspirate_pip assertions, firmware units = real * 10). + aspiration_types = _fill(backend_params.aspiration_type, [0] * n) + _assert_range(aspiration_types, 0, 2, "aspiration_type") + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + if not 0 <= min_z_endpos <= 3600: + raise ValueError("min_z_endpos must be 0-3600") + _assert_range([round(v * 10) for v in lld_search_height], 0, 3600, "lld_search_height") + _assert_range([round(v * 10) for v in clot_detection_height], 0, 500, "clot_detection_height") + _assert_range([round(v * 10) for v in liquid_surfaces_no_lld], 0, 3600, "liquid_surface_no_lld") + _assert_range( + [round(v * 10) for v in pull_out_distance_transport_air], + 0, + 3600, + "pull_out_distance_transport_air", + ) + _assert_range([round(v * 10) for v in second_section_height], 0, 3600, "second_section_height") + _assert_range([round(v * 10) for v in second_section_ratio], 0, 10000, "second_section_ratio") + _assert_range([round(v * 10) for v in minimum_height], 0, 3600, "minimum_height") + _assert_range([round(v * 10) for v in immersion_depth], 0, 3600, "immersion_depth") + _assert_range(immersion_depth_direction, 0, 1, "immersion_depth_direction") + _assert_range( + [round(v * 10) for v in surface_following_distance], 0, 3600, "surface_following_distance" + ) + _assert_range([round(v * 10) for v in volumes], 0, 12500, "aspiration_volumes") + _assert_range([round(v * 10) for v in flow_rates], 4, 5000, "aspiration_speed") + _assert_range([round(v * 10) for v in transport_air_volume], 0, 500, "transport_air_volume") + _assert_range([round(v * 10) for v in blow_out_air_volumes], 0, 9999, "blow_out_air_volume") + _assert_range([round(v * 10) for v in pre_wetting_volume], 0, 999, "pre_wetting_volume") + _assert_range([m.value for m in lld_mode], 0, 4, "lld_mode") + _assert_range(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity") + _assert_range(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity") + _assert_range( + [round(v * 10) for v in aspirate_position_above_z_touch_off], + 0, + 100, + "aspirate_position_above_z_touch_off", + ) + _assert_range( + [round(v * 10) for v in detection_height_difference_for_dual_lld], + 0, + 99, + "detection_height_difference_for_dual_lld", + ) + _assert_range([round(v * 10) for v in swap_speed], 3, 1600, "swap_speed") + _assert_range([round(v * 10) for v in settling_time], 0, 99, "settling_time") + _assert_range([round(v * 10) for v in mix_volume], 0, 12500, "mix_volume") + _assert_range(mix_cycles, 0, 99, "mix_cycles") + _assert_range( + [round(v * 10) for v in mix_position_from_liquid_surface], + 0, + 900, + "mix_position_from_liquid_surface", + ) + _assert_range([round(v * 10) for v in mix_speed], 4, 5000, "mix_speed") + _assert_range( + [round(v * 10) for v in mix_surface_following_distance], + 0, + 3600, + "mix_surface_following_distance", + ) + _assert_range(limit_curve_index, 0, 999, "limit_curve_index") + if not 0 <= backend_params.recording_mode <= 2: + raise ValueError("recording_mode must be between 0 and 2") + # 2nd section aspiration range checks + _assert_range( + [ + round(v * 10) + for v in _fill(backend_params.retract_height_over_2nd_section_to_empty_tip, [0.0] * n) + ], + 0, + 3600, + "retract_height_over_2nd_section_to_empty_tip", + ) + _assert_range( + [ + round(v * 10) + for v in _fill(backend_params.dispensation_speed_during_emptying_tip, [50.0] * n) + ], + 4, + 5000, + "dispensation_speed_during_emptying_tip", + ) + _assert_range( + [ + round(v * 10) + for v in _fill(backend_params.dosing_drive_speed_during_2nd_section_search, [50.0] * n) + ], + 4, + 5000, + "dosing_drive_speed_during_2nd_section_search", + ) + _assert_range( + [ + round(v * 10) + for v in _fill(backend_params.z_drive_speed_during_2nd_section_search, [30.0] * n) + ], + 3, + 1600, + "z_drive_speed_during_2nd_section_search", + ) + _assert_range( + [round(v * 10) for v in _fill(backend_params.cup_upper_edge, [0.0] * n)], + 0, + 3600, + "cup_upper_edge", + ) + + try: + await self.driver.send_command( + module="C0", + command="AS", + tip_pattern=channels_involved, + read_timeout=max(300, self.driver.read_timeout), + at=[f"{at:01}" for at in aspiration_types], + tm=channels_involved, + 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"{round(lsh * 10):04}" for lsh in lld_search_height], + ch=[f"{round(cd * 10):03}" for cd in clot_detection_height], + zl=[f"{round(ls * 10):04}" for ls in liquid_surfaces_no_lld], + po=[f"{round(po * 10):04}" for po in pull_out_distance_transport_air], + zu=[f"{round(sh * 10):04}" for sh in second_section_height], + zr=[f"{round(sr * 10):05}" for sr in second_section_ratio], + zx=[f"{round(mh * 10):04}" for mh in minimum_height], + ip=[f"{round(id_ * 10):04}" for id_ in immersion_depth], + it=[f"{idd}" for idd in immersion_depth_direction], + fp=[f"{round(sfd * 10):04}" for sfd in surface_following_distance], + av=[f"{round(vol * 10):05}" for vol in volumes], + as_=[f"{round(fr * 10):04}" for fr in flow_rates], + ta=[f"{round(tav * 10):03}" for tav in transport_air_volume], + ba=[f"{round(boa * 10):04}" for boa in blow_out_air_volumes], + oa=[f"{round(pwv * 10):03}" for pwv in pre_wetting_volume], + lm=[f"{mode.value}" for mode in lld_mode], + ll=[f"{s}" for s in gamma_lld_sensitivity], + lv=[f"{s}" for s in dp_lld_sensitivity], + zo=[f"{round(ap * 10):03}" for ap in aspirate_position_above_z_touch_off], + ld=[f"{round(dh * 10):02}" for dh in detection_height_difference_for_dual_lld], + de=[f"{round(ss * 10):04}" for ss in swap_speed], + wt=[f"{round(st * 10):02}" for st in settling_time], + mv=[f"{round(v * 10):05}" for v in mix_volume], + mc=[f"{c:02}" for c in mix_cycles], + mp=[f"{round(p * 10):03}" for p in mix_position_from_liquid_surface], + ms=[f"{round(s * 10):04}" for s in mix_speed], + mh=[f"{round(d * 10):04}" for d in mix_surface_following_distance], + gi=[f"{i:03}" for i in limit_curve_index], + gj=backend_params.tadm_algorithm, + gk=backend_params.recording_mode, + lk=[1 if x else 0 for x in _fill(backend_params.use_2nd_section_aspiration, [False] * n)], + ik=[ + f"{round(x * 10):04}" + for x in _fill(backend_params.retract_height_over_2nd_section_to_empty_tip, [0.0] * n) + ], + sd=[ + f"{round(x * 10):04}" + for x in _fill(backend_params.dispensation_speed_during_emptying_tip, [50.0] * n) + ], + se=[ + f"{round(x * 10):04}" + for x in _fill(backend_params.dosing_drive_speed_during_2nd_section_search, [50.0] * n) + ], + sz=[ + f"{round(x * 10):04}" + for x in _fill(backend_params.z_drive_speed_during_2nd_section_search, [30.0] * n) + ], + io=[f"{round(x * 10):04}" for x in _fill(backend_params.cup_upper_edge, [0.0] * n)], + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- dispense --------------------------------------------------------------- + + @dataclass + class DispenseParams(BackendParams): + """STAR-specific parameters for ``dispense``. + + All per-channel list parameters accept ``None`` to use sensible defaults (typically + derived from liquid classes or container geometry). When provided, lists must have one + entry per channel involved in the operation. + + Dispensing modes are controlled by the combination of ``jet``, ``blow_out``, and + ``empty`` flags: + - jet=False, blow_out=False, empty=False: Partial volume at surface (mode 2) + - jet=True, blow_out=False: Partial volume in jet mode (mode 0) + - jet=True, blow_out=True: Blow out in jet mode (mode 1) + - jet=False, blow_out=True: Blow out at surface (mode 3) + - empty=True: Empty tip at fix position (mode 4) + + LLD restrictions: + - During dispensation, all pressure-based LLD (dP, Dual) is set to OFF. + - "side touch off" turns LLD and "Z touch off" to OFF. + + Args: + hamilton_liquid_classes: Per-channel Hamilton liquid class overrides. If None, + auto-detected from tip type and liquid. + disable_volume_correction: Per-channel flag to disable liquid-class volume correction. + jet: Per-channel flag for jet dispensing mode. + blow_out: Per-channel flag for blow out dispensing mode. + empty: Per-channel flag for empty tip mode. + lld_search_height: LLD search height in mm (relative to well bottom). If None, + computed from container geometry. Must be between 0 and 360.0. + liquid_surface_no_lld: Absolute liquid surface position in mm when not using LLD. + If None, computed from well bottom + liquid height. + pull_out_distance_transport_air: Distance in mm to pull out for transport air when + not using LLD. Must be between 0 and 360.0. Default 10.0. + second_section_height: Tube 2nd section height measured from minimum_height in mm. + Must be between 0 and 360.0. Default 3.2. + second_section_ratio: Tube 2nd section ratio: (bottom diameter * 10000) / top + diameter. Must be between 0 and 1000.0. Default 618.0. + minimum_height: Minimum height (maximum immersion depth) in mm. If None, uses well + bottom. Must be between 0 and 360.0. + immersion_depth: Immersion depth in mm. Must be between 0 and 360.0. + immersion_depth_direction: Direction of immersion depth per channel (0 = go deeper, + 1 = go up out of liquid). If None, inferred from sign of immersion_depth. + surface_following_distance: Surface following distance during dispensing in mm. + Must be between 0 and 360.0. + cut_off_speed: Cut-off speed in uL/s. Must be between 0.4 and 500.0. + stop_back_volume: Stop back volume in uL. Must be between 0 and 18.0. + transport_air_volume: Transport air volume in uL. If None, uses liquid class + default. Must be between 0 and 50.0. + lld_mode: LLD mode per channel (OFF, GAMMA, DP, DUAL, Z_TOUCH_OFF). Default OFF. + side_touch_off_distance: Side touch off distance in mm (0 = OFF). Turns LLD and + Z touch off to OFF if enabled. Must be between 0 and 4.5. Default 0.0. + dispense_position_above_z_touch_off: Dispense position above Z touch off in mm. + Must be between 0 and 10.0. + gamma_lld_sensitivity: Gamma LLD sensitivity per channel (1 = high, 4 = low). + Must be between 1 and 4. + dp_lld_sensitivity: Delta-P LLD sensitivity per channel (1 = high, 4 = low). + Must be between 1 and 4. + swap_speed: Swap speed (on leaving liquid) in mm/s. If None, uses liquid class + default. Must be between 0.3 and 160.0. + settling_time: Settling time in seconds. If None, uses liquid class default. + Must be between 0 and 9.9. + mix_position_from_liquid_surface: Mix position in Z direction from liquid surface + in mm. Must be between 0 and 90.0. + mix_surface_following_distance: Surface following distance during mix in mm. + Must be between 0 and 360.0. + limit_curve_index: Limit curve index for TADM. Must be between 0 and 999. + minimum_traverse_height_at_beginning_of_a_command: Minimum Z clearance in mm before + lateral movement. If None, uses backend's ``traversal_height``. Must be between + 0 and 360.0. + min_z_endpos: Minimum Z position in mm at end of command. If None, uses backend's + ``traversal_height``. Must be between 0 and 360.0. + tadm_algorithm: Whether to use the TADM algorithm. Default False. + recording_mode: Recording mode (0 = no recording, 1 = TADM errors only, + 2 = all TADM measurements). Must be between 0 and 2. + probe_liquid_height: If True, use gamma LLD to probe the liquid height before + dispensing. Cannot be used when liquid heights are already set on operations. + auto_surface_following_distance: If True, automatically compute the surface + following distance from volume and container geometry. + """ + + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + empty: Optional[List[bool]] = None + 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 + immersion_depth_direction: Optional[List[int]] = 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 + side_touch_off_distance: float = 0.0 + 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[float] = None + min_z_endpos: Optional[float] = None + tadm_algorithm: bool = False + recording_mode: int = 0 + probe_liquid_height: bool = False + auto_surface_following_distance: bool = False + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.DispenseParams): + backend_params = STARPIPBackend.DispenseParams() + + await self.driver.ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "dispense") + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + n = len(ops) + + # Dispensing mode. + jet = backend_params.jet or [False] * n + blow_out = backend_params.blow_out or [False] * n + empty = backend_params.empty or [False] * n + dispensing_modes = [ + _dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) for i in range(n) + ] + + # Resolve liquid classes. + hlcs = _resolve_liquid_classes( + backend_params.hamilton_liquid_classes, ops, jet=jet, blow_out=blow_out, is_aspirate=False + ) + + # Well bottoms. + well_bottoms = [ + op.resource.get_absolute_location(x="c", y="c", z="b").z + + op.offset.z + + op.resource.material_z_thickness + for op in ops + ] + + # LLD search height. + if backend_params.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, backend_params.lld_search_height) + ] + + pull_out_distance_transport_air = _fill( + backend_params.pull_out_distance_transport_air, [10.0] * n + ) + second_section_height = _fill(backend_params.second_section_height, [3.2] * n) + second_section_ratio = _fill(backend_params.second_section_ratio, [618.0] * n) + minimum_height = _fill(backend_params.minimum_height, well_bottoms) + + immersion_depth_raw = backend_params.immersion_depth or [0.0] * n + immersion_depth_direction = backend_params.immersion_depth_direction or [ + 0 if id_ >= 0 else 1 for id_ in immersion_depth_raw + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth_raw) + ] + + surface_following_distance = _fill(backend_params.surface_following_distance, [0.0] * n) + + # Volumes (with liquid class correction). + disable_vc = _fill(backend_params.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, hlcs, disable_vc) + ] + + # Flow rates (liquid class default). + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120.0) + for op, hlc in zip(ops, hlcs) + ] + + logger.info( + "[STAR PIP] dispense: resource=%s, channels=%s, volumes=%s, flow_rates=%s", + ops[0].resource.parent.name if ops[0].resource.parent else ops[0].resource.name, + use_channels, + [round(v, 3) for v in volumes], + [round(fr, 3) for fr in flow_rates], + ) + + cut_off_speed = _fill(backend_params.cut_off_speed, [5.0] * n) + stop_back_volume = _fill( + backend_params.stop_back_volume, + [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + transport_air_volume = _fill( + backend_params.transport_air_volume, + [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + 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, hlcs) + ] + + lld_mode = _fill(backend_params.lld_mode, [LLDMode.OFF] * n) + dispense_position_above_z_touch_off = _fill( + backend_params.dispense_position_above_z_touch_off, [0.0] * n + ) + gamma_lld_sensitivity = _fill(backend_params.gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = _fill(backend_params.dp_lld_sensitivity, [1] * n) + swap_speed = _fill( + backend_params.swap_speed, + [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs], + ) + settling_time = _fill( + backend_params.settling_time, + [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs], + ) + + # Mix. + 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( + backend_params.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(backend_params.mix_surface_following_distance, [0.0] * n) + limit_curve_index = _fill(backend_params.limit_curve_index, [0] * n) + + side_touch_off_distance = round(backend_params.side_touch_off_distance * 10) + + # Probe liquid height if requested. + traverse_height_override = backend_params.minimum_traverse_height_at_beginning_of_a_command + if backend_params.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.driver.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + move_to_z_safety_after=False, + ) + logger.info("Detected liquid heights: %s", liquid_heights) + traverse_height_override = 100.0 + else: + liquid_heights = [op.liquid_height or 0.0 for op in ops] + + # Auto surface following distance. + if backend_params.auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not backend_params.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( + "auto_surface_following_distance requires containers with height<->volume functions." + ) + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + liquid_height_after = [ + op.resource.compute_height_from_volume(current_volumes[i] + op.volume) + for i, op in enumerate(ops) + ] + surface_following_distance = [liquid_height_after[i] - liquid_heights[i] for i in range(n)] + + liquid_surfaces_no_lld = backend_params.liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + minimum_traverse_height_at_beginning_of_a_command = round( + (traverse_height_override or self.traversal_height) * 10 + ) + min_z_endpos = round((backend_params.min_z_endpos or self.traversal_height) * 10) + + # Range validation (matches legacy dispense_pip assertions, firmware units = real * 10). + _assert_range(dispensing_modes, 0, 4, "dispensing_mode") + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + _assert_range([round(v * 10) for v in minimum_height], 0, 3600, "minimum_height") + _assert_range([round(v * 10) for v in lld_search_height], 0, 3600, "lld_search_height") + _assert_range([round(v * 10) for v in liquid_surfaces_no_lld], 0, 3600, "liquid_surface_no_lld") + _assert_range( + [round(v * 10) for v in pull_out_distance_transport_air], + 0, + 3600, + "pull_out_distance_transport_air", + ) + _assert_range([round(v * 10) for v in immersion_depth], 0, 3600, "immersion_depth") + _assert_range(immersion_depth_direction, 0, 1, "immersion_depth_direction") + _assert_range( + [round(v * 10) for v in surface_following_distance], 0, 3600, "surface_following_distance" + ) + _assert_range([round(v * 10) for v in second_section_height], 0, 3600, "second_section_height") + _assert_range([round(v * 10) for v in second_section_ratio], 0, 10000, "second_section_ratio") + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + if not 0 <= min_z_endpos <= 3600: + raise ValueError("min_z_endpos must be 0-3600") + _assert_range([round(v * 10) for v in volumes], 0, 12500, "dispense_volumes") + _assert_range([round(v * 10) for v in flow_rates], 4, 5000, "dispense_speed") + _assert_range([round(v * 10) for v in cut_off_speed], 4, 5000, "cut_off_speed") + _assert_range([round(v * 10) for v in stop_back_volume], 0, 180, "stop_back_volume") + _assert_range([round(v * 10) for v in transport_air_volume], 0, 500, "transport_air_volume") + _assert_range([round(v * 10) for v in blow_out_air_volumes], 0, 9999, "blow_out_air_volume") + _assert_range([m.value for m in lld_mode], 0, 4, "lld_mode") + if not 0 <= side_touch_off_distance <= 45: + raise ValueError("side_touch_off_distance must be between 0 and 45") + _assert_range( + [round(v * 10) for v in dispense_position_above_z_touch_off], + 0, + 100, + "dispense_position_above_z_touch_off", + ) + _assert_range(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity") + _assert_range(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity") + _assert_range([round(v * 10) for v in swap_speed], 3, 1600, "swap_speed") + _assert_range([round(v * 10) for v in settling_time], 0, 99, "settling_time") + _assert_range([round(v * 10) for v in mix_volume], 0, 12500, "mix_volume") + _assert_range(mix_cycles, 0, 99, "mix_cycles") + _assert_range( + [round(v * 10) for v in mix_position_from_liquid_surface], + 0, + 900, + "mix_position_from_liquid_surface", + ) + _assert_range([round(v * 10) for v in mix_speed], 4, 5000, "mix_speed") + _assert_range( + [round(v * 10) for v in mix_surface_following_distance], + 0, + 3600, + "mix_surface_following_distance", + ) + _assert_range(limit_curve_index, 0, 999, "limit_curve_index") + if not 0 <= backend_params.recording_mode <= 2: + raise ValueError("recording_mode must be between 0 and 2") + + try: + await self.driver.send_command( + module="C0", + command="DS", + tip_pattern=channels_involved, + read_timeout=max(300, self.driver.read_timeout), + dm=[f"{dm:01}" for dm in dispensing_modes], + tm=[f"{int(t):01}" for t in channels_involved], + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + zx=[f"{round(mh * 10):04}" for mh in minimum_height], + lp=[f"{round(lsh * 10):04}" for lsh in lld_search_height], + zl=[f"{round(ls * 10):04}" for ls in liquid_surfaces_no_lld], + po=[f"{round(po * 10):04}" for po in pull_out_distance_transport_air], + ip=[f"{round(id_ * 10):04}" for id_ in immersion_depth], + it=[f"{idd:01}" for idd in immersion_depth_direction], + fp=[f"{round(sfd * 10):04}" for sfd in surface_following_distance], + zu=[f"{round(sh * 10):04}" for sh in second_section_height], + zr=[f"{round(sr * 10):05}" for sr in second_section_ratio], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + dv=[f"{round(vol * 10):05}" for vol in volumes], + ds=[f"{round(fr * 10):04}" for fr in flow_rates], + ss=[f"{round(cs * 10):04}" for cs in cut_off_speed], + rv=[f"{round(sbv * 10):03}" for sbv in stop_back_volume], + ta=[f"{round(tav * 10):03}" for tav in transport_air_volume], + ba=[f"{round(boa * 10):04}" for boa in blow_out_air_volumes], + lm=[f"{mode.value:01}" for mode in lld_mode], + dj=f"{side_touch_off_distance:02}", + zo=[f"{round(dp * 10):03}" for dp in dispense_position_above_z_touch_off], + ll=[f"{s:01}" for s in gamma_lld_sensitivity], + lv=[f"{s:01}" for s in dp_lld_sensitivity], + de=[f"{round(ss * 10):04}" for ss in swap_speed], + wt=[f"{round(st * 10):02}" for st in settling_time], + mv=[f"{round(v * 10):05}" for v in mix_volume], + mc=[f"{c:02}" for c in mix_cycles], + mp=[f"{round(p * 10):03}" for p in mix_position_from_liquid_surface], + ms=[f"{round(s * 10):04}" for s in mix_speed], + mh=[f"{round(d * 10):04}" for d in mix_surface_following_distance], + gi=[f"{i:03}" for i in limit_curve_index], + gj=backend_params.tadm_algorithm, + gk=backend_params.recording_mode, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- can_pick_up_tip -------------------------------------------------------- + + 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 + + # -- multi-channel PIP operations ------------------------------------------ + + async def spread_pip_channels(self): + """Spread PIP channels (C0:JE).""" + return await self.send_command(module="C0", command="JE") + + async def move_all_channels_in_z_safety(self): + """Move all pipetting channels to Z-safety position (C0:ZA).""" + return await self.send_command(module="C0", command="ZA") + + async def move_all_pipetting_channels_to_defined_position( + self, + tip_pattern: bool = True, + x_positions: float = 0.0, + y_positions: float = 0.0, + minimum_traverse_height_at_beginning_of_command: float = 360.0, + z_endpos: float = 0.0, + ): + """Move all pipetting channels to defined position (C0:JM). + + Args: + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [mm]. Must be between 0 and 2500. Default 0. + y_positions: y positions [mm]. Must be between 0 and 650. Default 0. + minimum_traverse_height_at_beginning_of_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. Default 360. + z_endpos: Z-Position at end of a command [mm] (refers to all channels independent of tip + pattern parameter 'tm'). Must be between 0 and 360. Default 0. + """ + + if self.driver.iswap is not None and not self.driver.iswap.parked: + await self.driver.iswap.park() + + if not 0 <= x_positions <= 2500: + raise ValueError("x_positions must be between 0 and 2500") + if not 0 <= y_positions <= 650: + raise ValueError("y_positions must be between 0 and 650") + if not 0 <= minimum_traverse_height_at_beginning_of_command <= 360: + raise ValueError("minimum_traverse_height_at_beginning_of_command must be between 0 and 360") + if not 0 <= z_endpos <= 360: + raise ValueError("z_endpos must be between 0 and 360") + + return await self.driver.send_command( + module="C0", + command="JM", + tm=tip_pattern, + xp=round(x_positions * 10), + yp=round(y_positions * 10), + th=round(minimum_traverse_height_at_beginning_of_command * 10), + zp=round(z_endpos * 10), + ) + + async def get_channels_y_positions(self) -> Dict[int, float]: + """Get the Y position of all channels in mm (C0:RY).""" + resp = await self.driver.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 + # less than their minimum spacing apart (typically 9 mm). (When you set channels using + # position_channels_in_y_direction, it will raise an error.) The minimum y is 6mm, + # so we fix that first (in case that value is misreported). Then, we traverse the + # list in reverse and enforce pairwise minimum spacing. + if self.driver.extended_conf is not None: + min_y = self.driver.extended_conf.left_arm_min_y_position + else: + min_y = 6.0 + + 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}=min_spacing. We start with the channel closest to `back_channel`, and make sure the + # channel behind it is at least min_spacing away, updating if needed. + for channel_idx in range(back_channel, 0, -1): + spacing = self.driver._min_spacing_between(channel_idx - 1, channel_idx) + if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < spacing: + channel_locations[channel_idx - 1] = channel_locations[channel_idx] + spacing + + # Similarly for the channels to the front of `front_channel`, make sure they are all + # spaced >= min_spacing apart. + for channel_idx in range(front_channel, self.driver.num_channels - 1): + spacing = self.driver._min_spacing_between(channel_idx, channel_idx + 1) + if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < spacing: + channel_locations[channel_idx + 1] = channel_locations[channel_idx] - spacing + + # Quick checks before movement. + if channel_locations[0] > 650: + raise ValueError("Channel 0 would hit the back of the robot") + + if channel_locations[self.driver.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.driver._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.driver.send_command( + module="C0", + command="JY", + yp=yp, + ) + + async def get_channels_z_positions(self) -> Dict[int, float]: + """Get the Z position of all channels in mm (C0:RZ).""" + resp = await self.driver.send_command( + module="C0", + command="RZ", + fmt="rz#### (n)", + ) + return {channel_idx: round(z / 10, 2) for channel_idx, z in enumerate(resp["rz"])} + + async def position_channels_in_z_direction(self, zs: Dict[int, float]): + """Position channels in the Z direction (C0:JZ). + + Args: + zs: A dictionary mapping channel index to the desired Z position in mm. + """ + channel_locations = await self.get_channels_z_positions() + + for channel_idx, z in zs.items(): + channel_locations[channel_idx] = z + + return await self.driver.send_command( + module="C0", + command="JZ", + zp=[f"{round(z * 10):04}" for z in channel_locations.values()], + ) + + async def initialize_pip(self): + """Wrapper around initialize_pipetting_channels firmware command. + + Computes Y positions and calls initialize_pipetting_channels with default parameters. + """ + dy_01mm = (4050 - 2175) // (self.num_channels - 1) # integer division in 0.1mm, matching legacy + y_positions = [round((4050 - i * dy_01mm) / 10, 1) for i in range(self.num_channels)] + + tip_waste_x = 0.0 + if self.driver.extended_conf is not None: + tip_waste_x = self.driver.extended_conf.tip_waste_x_position + + await self.initialize_pipetting_channels( + x_positions=[tip_waste_x], + y_positions=y_positions, + begin_of_tip_deposit_process=self.traversal_height, + end_of_tip_deposit_process=122.0, + z_position_at_end_of_a_command=360.0, + tip_pattern=[True] * self.num_channels, + tip_type=4, + discarding_method=0, + ) + + async def initialize_pipetting_channels( + self, + x_positions: Optional[List[float]] = None, + y_positions: Optional[List[float]] = None, + begin_of_tip_deposit_process: float = 0.0, + end_of_tip_deposit_process: float = 0.0, + z_position_at_end_of_a_command: float = 360.0, + tip_pattern: Optional[List[bool]] = None, + tip_type: int = 16, + discarding_method: int = 1, + ): + """Initialize pipetting channels (discard tips) (C0:DI). + + Args: + x_positions: X-Position [mm] (discard position). Must be between 0 and 2500. Default [0]. + y_positions: y-Position [mm] (discard position). Must be between 0 and 650. Default [0]. + begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [mm]. Must be + between 0 and 360. Default 0. + end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [mm]. Must be + between 0 and 360. Default 0. + z_position_at_end_of_a_command: Z-Position at end of a command [mm]. Must be between 0 and + 360. Default 360. + tip_pattern: Tip pattern (channels involved). Default [True]. + tip_type: Tip type (recommended is index of longest tip see command 'TT'). 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. + """ + + if x_positions is None: + x_positions = [0.0] + if y_positions is None: + y_positions = [0.0] + if tip_pattern is None: + tip_pattern = [True] + + # Convert mm to 0.1mm for firmware + x_positions_fw = [round(x * 10) for x in x_positions] + y_positions_fw = [round(y * 10) for y in y_positions] + begin_fw = round(begin_of_tip_deposit_process * 10) + end_fw = round(end_of_tip_deposit_process * 10) + z_end_fw = round(z_position_at_end_of_a_command * 10) + + if not all(0 <= xp <= 25000 for xp in x_positions_fw): + raise ValueError("x_positions must be between 0 and 2500 mm") + if not all(0 <= yp <= 6500 for yp in y_positions_fw): + raise ValueError("y_positions must be between 0 and 650 mm") + if not 0 <= begin_fw <= 3600: + raise ValueError("begin_of_tip_deposit_process must be between 0 and 360 mm") + if not 0 <= end_fw <= 3600: + raise ValueError("end_of_tip_deposit_process must be between 0 and 360 mm") + if not 0 <= z_end_fw <= 3600: + raise ValueError("z_position_at_end_of_a_command must be between 0 and 360 mm") + if not 0 <= tip_type <= 99: + raise ValueError("tip_type must be between 0 and 99") + if not 0 <= discarding_method <= 1: + raise ValueError("discarding_method must be between 0 and 1") + + return await self.driver.send_command( + module="C0", + command="DI", + read_timeout=120, + xp=[f"{xp:05}" for xp in x_positions_fw], + yp=[f"{yp:04}" for yp in y_positions_fw], + tp=f"{begin_fw:04}", + tz=f"{end_fw:04}", + te=f"{z_end_fw:04}", + tm=[f"{tm:01}" for tm in tip_pattern], + tt=f"{tip_type:02}", + ti=discarding_method, + ) + + # -- single-channel movement ------------------------------------------------ + + 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 + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Measure tip presence on all channels using their sleeve sensors.""" + resp = await self.send_command(module="C0", command="RT", fmt="rt# (n)") + return [bool(v) for v in resp.get("rt")] + + async def prepare_for_manual_channel_operation(self, channel: int): + """Prepare for manual channel operation by moving all other channels out of the way (C0:JP). + + Args: + channel: 0-indexed channel index. + """ + if self.driver.iswap is not None and not self.driver.iswap.parked: + await self.driver.iswap.park() + + if not 0 <= channel < self.num_channels: + raise ValueError("channel must be between 0 and num_channels - 1") + + await self.driver.send_command( + module="C0", + command="JP", + pn=f"{channel + 1:02}", + ) + + # -- C0 multi-channel queries ------------------------------------------------ + + async def request_pip_height_last_lld(self) -> List[float]: + """Return absolute liquid heights (mm) from the last LLD event for each channel.""" + resp = await self.send_command(module="C0", command="RL", fmt="lh#### (n)") + return [float(v / 10) for v in resp.get("lh")] + + 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.driver.send_command(module="C0", command="FY") + + # -- foil piercing ---------------------------------------------------------- + + def _get_maximum_minimum_spacing_between_channels(self, use_channels: List[int]) -> float: + """Get the maximum of the set of minimum spacing requirements between the channels being used.""" + sorted_channels = sorted(use_channels) + return max( + self.driver._min_spacing_between(hi, lo) + for hi, lo in zip(sorted_channels[1:], sorted_channels[:-1]) + ) + + async def pierce_foil( + self, + wells: Union[Well, List[Well]], + piercing_channels: List[int], + hold_down_channels: List[int], + move_inwards: float, + deck: Resource, + 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. + move_inwards: mm to move inwards when stepping off the foil. + deck: The deck resource, used to compute absolute positions of wells. + 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. + distance_from_bottom: mm above the cavity bottom to position the piercing channels. + """ + + x: float + ys: List[float] + z: float + + # if only one well is given, 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(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=self._get_maximum_minimum_spacing_between_channels(piercing_channels), + ) + else: + offsets = get_tight_single_resource_liquid_op_offsets( + well, num_channels=len(piercing_channels) + ) + ys = [y + offset.y for offset in offsets] + else: + if len(set(w.get_location_wrt(deck).x for w in wells)) != 1: + raise ValueError("Wells must be on the same column") + absolute_center = wells[0].get_location_wrt(deck, "c", "c", "cavity_bottom") + x = absolute_center.x + ys = [well.get_location_wrt(deck, x="c", y="c").y for well in wells] + z = absolute_center.z + + assert self.driver.left_x_arm is not None + await self.driver.left_x_arm.move_to(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.channels[channel].move_tool_z(z + distance_from_bottom) + 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, + deck=deck, + ) + + async def step_off_foil( + self, + wells: Union[Well, List[Well]], + front_channel: int, + back_channel: int, + deck: Resource, + 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. + + 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. + deck: The deck resource, used to compute absolute positions of wells and plates. + 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) + if len(plates) != 1: + raise ValueError("All wells must be in the same plate") + plate = plates.pop() + if plate is None: + raise ValueError("Wells must have a parent plate") + + z_location = plate.get_location_wrt(deck, z="top").z + + if plate.get_absolute_rotation().z % 360 == 0: + back_location = plate.get_location_wrt(deck, y="b") + front_location = plate.get_location_wrt(deck, y="f") + elif plate.get_absolute_rotation().z % 360 == 90: + back_location = plate.get_location_wrt(deck, x="r") + front_location = plate.get_location_wrt(deck, x="l") + elif plate.get_absolute_rotation().z % 360 == 180: + back_location = plate.get_location_wrt(deck, y="f") + front_location = plate.get_location_wrt(deck, y="b") + elif plate.get_absolute_rotation().z % 360 == 270: + back_location = plate.get_location_wrt(deck, x="l") + front_location = plate.get_location_wrt(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, + } + ) + + # Use KZ directly rather than move_tool_z to avoid the extra firmware queries + # (tip presence, tip length, etc.) that move_tool_z performs. In this context + # we know the channels have tips and the target Z is the plate top. + await self.driver.send_command( + module="C0", + command="KZ", + pn=f"{front_channel + 1:02}", + zj=f"{round(z_location * 10):04}", + ) + await self.driver.send_command( + module="C0", + command="KZ", + pn=f"{back_channel + 1:02}", + zj=f"{round(z_location * 10):04}", + ) + 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() diff --git a/pylabrobot/hamilton/liquid_handlers/star/pip_channel.py b/pylabrobot/hamilton/liquid_handlers/star/pip_channel.py new file mode 100644 index 00000000000..2c98903e22d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_channel.py @@ -0,0 +1,1373 @@ +"""PIPChannel: represents a single PIP channel on the STAR.""" + +from __future__ import annotations + +import datetime +import enum +from typing import TYPE_CHECKING, Dict, Literal, Optional, Tuple + +from .errors import STARFirmwareError +from .fw_parsing import parse_star_firmware_version_date + +if TYPE_CHECKING: + from .driver import STARDriver + from .pip_backend import STARPIPBackend + + +# --------------------------------------------------------------------------- +# Drive-unit conversion helpers (mirrored from legacy STARBackend) +# --------------------------------------------------------------------------- + +_Z_DRIVE_MM_PER_INCREMENT = 0.01072765 +_DISPENSING_DRIVE_VOL_PER_INCREMENT = 0.046876 # uL / increment +_DISPENSING_DRIVE_MM_PER_INCREMENT = 0.002734375 # mm / increment +_Y_DRIVE_MM_PER_INCREMENT = 0.046302082 + +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 + + +def _mm_to_z_inc(mm: float) -> int: + return round(mm / _Z_DRIVE_MM_PER_INCREMENT) + + +def _z_inc_to_mm(inc: int) -> float: + return round(inc * _Z_DRIVE_MM_PER_INCREMENT, 2) + + +def _vol_to_disp_inc(vol: float) -> int: + return round(vol / _DISPENSING_DRIVE_VOL_PER_INCREMENT) + + +def _disp_inc_to_vol(inc: int) -> float: + return round(inc * _DISPENSING_DRIVE_VOL_PER_INCREMENT, 1) + + +def _mm_to_disp_inc(mm: float) -> int: + return round(mm / _DISPENSING_DRIVE_MM_PER_INCREMENT) + + +def _disp_inc_to_mm(inc: int) -> float: + return round(inc * _DISPENSING_DRIVE_MM_PER_INCREMENT, 3) + + +def _mm_to_y_inc(mm: float) -> int: + return round(mm / _Y_DRIVE_MM_PER_INCREMENT) + + +def _y_inc_to_mm(inc: int) -> float: + return round(inc * _Y_DRIVE_MM_PER_INCREMENT, 2) + + +# --------------------------------------------------------------------------- +# PressureLLDMode (was nested in legacy STARBackend) +# --------------------------------------------------------------------------- + + +class PressureLLDMode(enum.Enum): + """Pressure liquid level detection mode.""" + + LIQUID = 0 + FOAM = 1 + + +# --------------------------------------------------------------------------- +# PIPChannel +# --------------------------------------------------------------------------- + +DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = -45 # uL # TODO: confirm with others +DISPENSING_DRIVE_VOL_LIMIT_TOP = 1_250 # uL + + +class PIPChannel: + """Represents a single PIP channel on the STAR. + + Instances are created by :class:`STARPIPBackend` — one per physical channel. + """ + + def __init__(self, driver: STARDriver, index: int, backend: STARPIPBackend): + self.driver = driver + self.index = index + self.backend = backend + + async def send_command(self, *args, **kwargs): + """Send a firmware command. C0 commands are serialized; Px commands go direct.""" + return await self.backend.send_command(*args, **kwargs) + + @property + def module_id(self) -> str: + """Firmware module identifier for this channel (e.g. ``"P1"``).""" + return "P" + "123456789ABCDEFG"[self.index] + + # -- Px:RF firmware version ------------------------------------------------ + + async def request_firmware_version(self) -> datetime.date: + """Query the firmware version of this channel (Px:RF).""" + resp = await self.send_command( + module=self.module_id, + command="RF", + fmt="rf" + "&" * 17, + ) + return parse_star_firmware_version_date(str(resp["rf"])) + + # -- Px:RV cycle counts ---------------------------------------------------- + + async def request_cycle_counts(self) -> Dict[str, int]: + """Request cycle counters for a single channel. + + Returns the number of tip pick-up, tip discard, aspiration, and dispensing cycles + performed by the channel. + + Returns: + A dict with keys ``tip_pick_up_cycles``, ``tip_discard_cycles``, + ``aspiration_cycles``, and ``dispensing_cycles``. + """ + + resp = await self.send_command( + module=self.module_id, + 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"], + } + + # -- Px:RD dispensing drive position --------------------------------------- + + async def request_dispensing_drive_position(self) -> float: + """Request the current position of the channel's dispensing drive""" + + resp = await self.send_command( + module=self.module_id, + command="RD", + fmt="rd##### #####", + ) + return _disp_inc_to_vol(resp["rd"]) + + # -- Px:DS move dispensing drive ------------------------------------------- + + async def move_dispensing_drive_to_position( + self, + 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: + 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 (DISPENSING_DRIVE_VOL_LIMIT_BOTTOM <= vol <= DISPENSING_DRIVE_VOL_LIMIT_TOP): + raise ValueError( + f"Target dispensing Drive vol must be between {DISPENSING_DRIVE_VOL_LIMIT_BOTTOM}" + f" and {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.request_dispensing_drive_position() + relative_vol_movement = round(vol - current_position, 1) + relative_vol_movement_increment = _vol_to_disp_inc(abs(relative_vol_movement)) + speed_increment = _vol_to_disp_inc(flow_rate) + acceleration_increment = _vol_to_disp_inc(acceleration) + acceleration_increment_thousands = round(acceleration_increment * 0.001) + + await self.send_command( + module=self.module_id, + 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}", + ) + + # -- empty_tip (convenience over Px:DS) ------------------------------------ + + async def empty_tip( + self, + 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: + 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 = DISPENSING_DRIVE_VOL_LIMIT_BOTTOM + + # Empty tip + await self.move_dispensing_drive_to_position( + 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.move_dispensing_drive_to_position( + vol=0, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + ) + + # -- Px:ZA move Z-drive (stop disk reference) ------------------------------ + + async def move_stop_disk_z( + self, + z: float, + speed: float = 125.0, + acceleration: float = 800.0, + current_limit: int = 3, + ): + """Move this channel's Z-drive to an absolute stop disk position. + + Communicates directly with the channel (Px:ZA) rather than through the + master module (C0:KZ). This bypasses the firmware's tip-picked-up flag, + enabling Z moves with configurable speed/acceleration. + + Args: + 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_inc = _mm_to_z_inc(z) + speed_inc = _mm_to_z_inc(speed) + accel_inc = _mm_to_z_inc(acceleration / 1000) + + if not (9320 <= z_inc <= 31200): + raise ValueError( + f"z must be between {_z_inc_to_mm(9320)} and {_z_inc_to_mm(31200)} mm, got {z} mm" + ) + if not (20 <= speed_inc <= 15000): + raise ValueError( + f"speed must be between {_z_inc_to_mm(20)} and {_z_inc_to_mm(15000)} mm/s, got {speed} mm/s" + ) + if not (5 <= accel_inc <= 150): + raise ValueError( + f"acceleration must be between ~53.6 and ~1609 mm/s², got {acceleration} mm/s²" + ) + if not (0 <= current_limit <= 7): + raise ValueError(f"current_limit must be between 0 and 7, got {current_limit}") + + return await self.send_command( + module=self.module_id, + command="ZA", + za=f"{z_inc:05}", + zv=f"{speed_inc:05}", + zr=f"{accel_inc:03}", + zw=f"{current_limit:01}", + ) + + async def move_to_z_safety(self): + """Move this channel to the maximum Z position (safe height) using Px:ZA.""" + await self.move_stop_disk_z(self.MAXIMUM_CHANNEL_Z_POSITION) + + # -- move tool Z (tip/tool end reference) ------------------------------------ + + MAXIMUM_CHANNEL_Z_POSITION = 334.7 # mm + MINIMUM_CHANNEL_Z_POSITION = 99.98 # mm + DEFAULT_TIP_FITTING_DEPTH = 8 # mm + + async def move_tool_z(self, z: float): + """Move this channel in the Z direction, referenced to the tip/tool end (mm). + + Requires a tip or tool to be attached. Use :meth:`move_stop_disk_z` for moves + without a tip. + + Args: + z: Target Z position in mm (tip/tool end). + """ + tip_presence = await self.backend.request_tip_presence() + if not tip_presence[self.index]: + raise ValueError( + f"Channel {self.index} does not have a tip or tool attached. " + "Use move_stop_disk_z() for Z moves without a tip attached." + ) + + tip_len = await self.request_tip_length() + + max_tip_z = self.MAXIMUM_CHANNEL_Z_POSITION - tip_len + self.DEFAULT_TIP_FITTING_DEPTH + min_tip_z = self.MINIMUM_CHANNEL_Z_POSITION - tip_len + self.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 {self.index}" + ) + + await self.send_command( + module="C0", + command="KZ", + pn=f"{self.index + 1:02}", + zj=f"{round(z * 10):04}", + ) + + # -- delegate to left_x_arm (C0 RX) — channels share the X carriage ---------- + # TODO: we assume `C0RX` references the center of the x-arm, figure out what it + # references for half-arms (see issue 822 and new Fluid Motion STAR) + + async def request_x_pos(self) -> float: + """Request current X-position of this channel (mm). + + All PIP channels share the same X arm, so this returns the arm position. + """ + assert self.driver.left_x_arm is not None, "left_x_arm not set; call driver.setup() first" + return await self.driver.left_x_arm.request_position() + + # -- C0:RB request Y position ------------------------------------------------ + + async def request_y_pos(self) -> float: + """Request current Y-position of this channel (mm).""" + resp = await self.driver.send_command( + module="C0", + command="RB", + fmt="rb####", + pn=f"{self.index + 1:02}", + ) + return float(resp["rb"] / 10) + + # -- C0:KY move channel Y --------------------------------------------------- + + async def move_y(self, y: float): + """Move this channel safely in the Y direction (mm), with anti-crash checks. + + Enforces minimum spacing between neighboring channels (queried from firmware + during setup) so that two channels can never be commanded closer than the + hardware allows. + + Args: + y: Target Y position in mm. + """ + assert self.driver.extended_conf is not None + if self.index > 0: + adj_y = await self.backend.channels[self.index - 1].request_y_pos() + spacing = self.driver._min_spacing_between(self.index, self.index - 1) + max_y_pos = adj_y - spacing + if y > max_y_pos: + raise ValueError( + f"channel {self.index} y-target {y} mm too close to channel {self.index - 1} " + f"at {round(adj_y, 2)} mm (minimum spacing {spacing} mm, " + f"max allowed {round(max_y_pos, 2)} mm)" + ) + else: + max_y_pos = self.driver.extended_conf.pip_maximal_y_position + if y > max_y_pos: + raise ValueError(f"channel {self.index} y-target must be <= {max_y_pos} mm") + + if self.index < (self.backend.num_channels - 1): + adj_y = await self.backend.channels[self.index + 1].request_y_pos() + spacing = self.driver._min_spacing_between(self.index, self.index + 1) + min_y_pos = adj_y + spacing + if y < min_y_pos: + raise ValueError( + f"channel {self.index} y-target {y} mm too close to channel {self.index + 1} " + f"at {round(adj_y, 2)} mm (minimum spacing {spacing} mm, " + f"min allowed {round(min_y_pos, 2)} mm)" + ) + else: + if y < self.driver.extended_conf.left_arm_min_y_position: + raise ValueError( + f"channel {self.index} y-target must be >= " + f"{self.driver.extended_conf.left_arm_min_y_position} mm" + ) + + y_position = round(y * 10) + if not 0 <= y_position <= 6500: + raise ValueError("y_position must be between 0 and 650 mm") + await self.send_command( + module="C0", + command="KY", + pn=f"{self.index + 1:02}", + yj=f"{y_position:04}", + ) + + # -- Px:RZ probe Z position ----------------------------------------------- + + async def request_probe_z_position(self) -> float: + """Request the z-position of the channel probe (EXCLUDING the tip)""" + resp = await self.send_command(module=self.module_id, command="RZ", fmt="rz######") + increments = resp["rz"] + return _z_inc_to_mm(increments) + + # -- C0:RD tip bottom Z position ------------------------------------------- + + async def request_tip_bottom_z_position(self) -> float: + """Request Z-position of the tip bottom on this channel (mm) (C0:RD). + + Raises RuntimeError if no tip is mounted. + """ + if not (await self.backend.request_tip_presence())[self.index]: + raise RuntimeError(f"No tip mounted on channel {self.index}") + resp = await self.driver.send_command( + module="C0", + command="RD", + fmt="rd####", + pn=f"{self.index + 1:02}", + ) + return float(resp["rd"] / 10) + + # -- tip length measurement -------------------------------------------------- + + async def request_tip_length(self) -> float: + """Measure the length of the tip on this channel (mm). + + Raises RuntimeError if no tip is present. + """ + tip_presence = await self.backend.request_tip_presence() + if not tip_presence[self.index]: + raise RuntimeError(f"No tip present on channel {self.index}") + + probe_z = await self.request_probe_z_position() + tip_bottom_z = await self.request_tip_bottom_z_position() + + fitting_depth = 8 # mm + return round(probe_z - (tip_bottom_z - fitting_depth), 1) + + # -- Px:QC volume in tip --------------------------------------------------- + + async def request_volume_in_tip(self) -> float: + resp = await self.send_command(self.module_id, "QC", fmt="qc##### (n)") + _, current_volume = resp["qc"] # first is max volume + return float(current_volume) / 10 + + # -- C0:QS TADM enabled ----------------------------------------------------- + + async def request_tadm_enabled(self) -> bool: + """Whether TADM (Total Aspiration and Dispensing Monitoring) is enabled on this channel.""" + resp = await self.driver.send_command( + module="C0", + command="QS", + fmt="qs# (n)", + ) + return bool(resp["qs"][self.index]) + + # -- Px:ZL cLLD Z search (low-level, head-space) -------------------------- + + async def search_z_using_clld( + self, + 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: + lowest_immers_pos: Lowest allowed search position in mm (hard stop). Defaults to 99.98. + start_pos_search: Search start position in mm. Defaults to 334.7. + 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 any parameter is outside the instrument-supported range. + """ + + # Conversions & machine-compatibility check of parameters + lowest_immers_pos_increments = _mm_to_z_inc(lowest_immers_pos) + start_pos_search_increments = _mm_to_z_inc(start_pos_search) + channel_speed_increments = _mm_to_z_inc(channel_speed) + channel_acceleration_thousand_increments = _mm_to_z_inc(channel_acceleration / 1000) + post_detection_dist_increments = _mm_to_z_inc(post_detection_dist) + + if not (9_320 <= lowest_immers_pos_increments <= 31_200): + raise ValueError( + f"Lowest immersion position must be between \n{_z_inc_to_mm(9_320)}" + + f" and {_z_inc_to_mm(31_200)} mm, is {lowest_immers_pos} mm" + ) + if not (9_320 <= start_pos_search_increments <= 31_200): + raise ValueError( + f"Start position of LLD search must be between \n{_z_inc_to_mm(9_320)}" + + f" and {_z_inc_to_mm(31_200)} mm, is {start_pos_search} mm" + ) + if not (20 <= channel_speed_increments <= 15_000): + raise ValueError( + f"LLD search speed must be between \n{_z_inc_to_mm(20)}" + + f"and {_z_inc_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + if not (5 <= channel_acceleration_thousand_increments <= 150): + raise ValueError( + f"Channel acceleration must be between \n{_z_inc_to_mm(5 * 1_000)} " + + f" and {_z_inc_to_mm(150 * 1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" + ) + if not (0 <= detection_edge <= 1_023): + raise ValueError("Edge steepness at capacitive LLD detection must be between 0 and 1023") + if not (0 <= detection_drop <= 1_023): + raise ValueError("Offset after capacitive LLD edge detection must be between 0 and 1023") + if not (0 <= post_detection_dist_increments <= 9_999): + raise ValueError( + "Post cLLD-detection movement distance must be between \n0" + + f" and {_z_inc_to_mm(9_999)} mm, is {post_detection_dist} mm" + ) + + await self.send_command( + module=self.module_id, + 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] + ) + + # -- Px:ZE pLLD Z search (low-level, head-space) -------------------------- + + async def search_z_using_plld( + self, + 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) + """ + + if plld_mode is None: + plld_mode = 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 = _vol_to_disp_inc(dispense_back_plld_volume) + + # Conversions to machine units + lowest_immers_pos_increments = _mm_to_z_inc(lowest_immers_pos) + start_pos_search_increments = _mm_to_z_inc(start_pos_search) + + channel_speed_above_start_pos_search_increments = _mm_to_z_inc( + channel_speed_above_start_pos_search + ) + channel_speed_increments = _mm_to_z_inc(channel_speed) + channel_acceleration_thousand_increments = _mm_to_z_inc(channel_acceleration / 1000) + + dispense_drive_speed_increments = _mm_to_disp_inc(dispense_drive_speed) + dispense_drive_acceleration_increments = _mm_to_disp_inc(dispense_drive_acceleration) + dispense_drive_max_speed_increments = _mm_to_disp_inc(dispense_drive_max_speed) + + post_detection_dist_increments = _mm_to_z_inc(post_detection_dist) + max_delta_plld_clld_increments = _mm_to_z_inc(max_delta_plld_clld) + + plld_foam_search_speed_increments = _mm_to_z_inc(plld_foam_search_speed) + + # Machine-compatibility parameter checks + if not (9_320 <= lowest_immers_pos_increments <= 31_200): + raise ValueError( + f"Lowest immersion position must be between \n{_z_inc_to_mm(9_320)}" + + f" and {_z_inc_to_mm(31_200)} mm, is {lowest_immers_pos} mm" + ) + if not (9_320 <= start_pos_search_increments <= 31_200): + raise ValueError( + f"Start position of LLD search must be between \n{_z_inc_to_mm(9_320)}" + + f" and {_z_inc_to_mm(31_200)} mm, is {start_pos_search} mm" + ) + + if tip_has_filter not in [True, False]: + raise TypeError("tip_has_filter must be a boolean") + + if not isinstance(clld_verification, bool): + raise TypeError(f"clld_verification must be a boolean, is {clld_verification}") + + if plld_mode not in [PressureLLDMode.LIQUID, PressureLLDMode.FOAM]: + raise ValueError( + f"plld_mode must be either PressureLLDMode.LIQUID ({PressureLLDMode.LIQUID}) or " + + f"PressureLLDMode.FOAM ({PressureLLDMode.FOAM}), is {plld_mode}" + ) + + if not (20 <= channel_speed_above_start_pos_search_increments <= 15_000): + raise ValueError( + "Speed above start position of LLD search must be between \n" + + f"{_z_inc_to_mm(20)} and " + + f"{_z_inc_to_mm(15_000)} mm/sec, is " + + f"{channel_speed_above_start_pos_search} mm/sec" + ) + if not (20 <= channel_speed_increments <= 15_000): + raise ValueError( + f"LLD search speed must be between \n{_z_inc_to_mm(20)}" + + f"and {_z_inc_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + if not (5 <= channel_acceleration_thousand_increments <= 150): + raise ValueError( + f"Channel acceleration must be between \n{_z_inc_to_mm(5 * 1_000)} " + + f" and {_z_inc_to_mm(150 * 1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" + ) + if not (0 <= z_drive_current_limit <= 7): + raise ValueError(f"Z-drive current limit must be between 0 and 7, is {z_drive_current_limit}") + + if not (20 <= dispense_drive_speed_increments <= 13_500): + raise ValueError( + "Dispensing drive speed must be between \n" + + f"{_disp_inc_to_mm(20)} and " + + f"{_disp_inc_to_mm(13_500)} mm/sec, is {dispense_drive_speed} mm/sec" + ) + if not (1 <= dispense_drive_acceleration_increments <= 100): + raise ValueError( + "Dispensing drive acceleration must be between \n" + + f"{_disp_inc_to_mm(1)} and " + + f"{_disp_inc_to_mm(100)} mm/sec**2, is {dispense_drive_acceleration} mm/sec**2" + ) + if not (20 <= dispense_drive_max_speed_increments <= 13_500): + raise ValueError( + "Dispensing drive max speed must be between \n" + + f"{_disp_inc_to_mm(20)} and " + + f"{_disp_inc_to_mm(13_500)} mm/sec, is {dispense_drive_max_speed} mm/sec" + ) + if not (0 <= dispense_drive_current_limit <= 7): + raise ValueError( + f"Dispensing drive current limit must be between 0 and 7, is {dispense_drive_current_limit}" + ) + + if not (0 <= clld_detection_edge <= 1_023): + raise ValueError("Edge steepness at capacitive LLD detection must be between 0 and 1023") + if not (0 <= clld_detection_drop <= 1_023): + raise ValueError("Offset after capacitive LLD edge detection must be between 0 and 1023") + if not (0 <= plld_detection_edge <= 1_023): + raise ValueError("Edge steepness at pressure LLD detection must be between 0 and 1023") + if not (0 <= plld_detection_drop <= 1_023): + raise ValueError("Offset after pressure LLD edge detection must be between 0 and 1023") + + if not (0 <= max_delta_plld_clld_increments <= 9_999): + raise ValueError( + "Maximum allowed difference between pressure LLD and capacitive LLD detection z-positions " + + f"must be between 0 and {_z_inc_to_mm(9_999)} mm," + + f" is {max_delta_plld_clld} mm" + ) + + if not (0 <= plld_foam_detection_drop <= 1_023): + raise ValueError( + f"Pressure LLD foam detection drop must be between 0 and 1023, is {plld_foam_detection_drop}" + ) + if not (0 <= plld_foam_detection_edge_tolerance <= 1_023): + raise ValueError( + "Pressure LLD foam detection edge tolerance must be between 0 and 1023, " + + f"is {plld_foam_detection_edge_tolerance}" + ) + if not (0 <= plld_foam_ad_values <= 4_999): + raise ValueError( + f"Pressure LLD foam AD values must be between 0 and 4999, is {plld_foam_ad_values}" + ) + if not (20 <= plld_foam_search_speed_increments <= 13_500): + raise ValueError( + "Pressure LLD foam search speed must be between \n" + + f"{_z_inc_to_mm(20)} and " + + f"{_z_inc_to_mm(13_500)} mm/sec, is {plld_foam_search_speed} mm/sec" + ) + + if dispense_back_plld_volume_mode not in [0, 1]: + raise ValueError( + "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}" + ) + + if not (0 <= dispense_back_plld_volume_increments <= 26_666): + raise ValueError( + "Dispense back pressure LLD volume must be between \n0" + + f" and {_disp_inc_to_vol(26_666)} uL, is {dispense_back_plld_volume} uL" + ) + + if not (0 <= post_detection_dist_increments <= 9_999): + raise ValueError( + "Post cLLD-detection movement distance must be between \n0" + + f" and {_z_inc_to_mm(9_999)} mm, is {post_detection_dist} mm" + ) + + resp_raw = await self.send_command( + module=self.module_id, + 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.driver.read_timeout, 120), # it can take long (>30s) + ) + if resp_raw is None: + raise RuntimeError("No response received from pLLD search command") + + resp_probe_mm = [ + _z_inc_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 == PressureLLDMode.LIQUID + else (resp_probe_mm[0], resp_probe_mm[1]) + ) + + # -- Px:SI violently shoot down tip ---------------------------------------- + + async def violently_shoot_down_tip(self): + """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.send_command(module=self.module_id, command="SI") + + # --------------------------------------------------------------------------- + # Probe / query methods — delegate to backend C0 helpers as needed. + # --------------------------------------------------------------------------- + + async def clld_probe_z_height( + self, + 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 this channel's capacitive LLD (cLLD). + + Ensures a tip is mounted, reads the tip length, converts tip-referenced positions + to head-space coordinates, then delegates to :meth:`search_z_using_clld`. + + Args: + lowest_immers_pos: Lowest allowed search position in mm (tip-referenced). + start_pos_search: Start position for the cLLD search in mm (tip-referenced). + If None, the highest safe position is used based on tip length. + channel_speed: Search speed in mm/s. + channel_acceleration: Search acceleration in mm/s^2. + detection_edge: Edge steepness threshold for cLLD detection (0-1023). + detection_drop: Offset applied after cLLD edge detection (0-1023). + post_detection_trajectory: Firmware post-detection move mode (0 or 1). + post_detection_dist: Distance in mm to move after detection. + move_channels_to_safe_pos_after: If True, moves all channels to Z-safe after. + + Returns: + The detected liquid surface Z-height in mm. + + Raises: + RuntimeError: If no tip is mounted on this channel. + """ + assert self.backend is not None, "backend reference required for clld_probe_z_height" + + tip_presence = await self.backend.request_tip_presence() + if not tip_presence[self.index]: + raise RuntimeError(f"No tip mounted on channel {self.index}") + + tip_len = await self.request_tip_length() + safe_tip_top_z_pos = MAXIMUM_CHANNEL_Z_POSITION - tip_len + DEFAULT_TIP_FITTING_DEPTH + + if start_pos_search is None: + start_pos_search = safe_tip_top_z_pos + + if lowest_immers_pos < MINIMUM_CHANNEL_Z_POSITION: + raise ValueError( + f"lowest_immers_pos must be at least {MINIMUM_CHANNEL_Z_POSITION} mm " + f"but is {lowest_immers_pos} mm" + ) + + # Convert tip-space to head-space + lowest_immers_pos_head_space = lowest_immers_pos + tip_len - DEFAULT_TIP_FITTING_DEPTH + channel_head_start_pos = round(start_pos_search + tip_len - DEFAULT_TIP_FITTING_DEPTH, 2) + + if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): + raise ValueError( + f"Start position of LLD search must be between " + f"{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" + ) + + try: + await self.search_z_using_clld( + 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_to_z_safety() + raise + + if move_channels_to_safe_pos_after: + await self.move_to_z_safety() + + heights = await self.backend.request_pip_height_last_lld() + return heights[self.index] + + async def plld_probe_z_height( + self, + lowest_immers_pos: float = 99.98, + start_pos_search: Optional[float] = None, + channel_speed_above_start_pos_search: float = 120.0, + channel_speed: float = 10.0, + channel_acceleration: float = 800.0, + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, + dispense_drive_acceleration: float = 0.2, + dispense_drive_max_speed: float = 14.5, + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, + clld_detection_edge: int = 10, + clld_detection_drop: int = 2, + max_delta_plld_clld: float = 5.0, + plld_mode: Optional[PressureLLDMode] = None, + plld_foam_detection_drop: int = 30, + plld_foam_detection_edge_tolerance: int = 30, + plld_foam_ad_values: int = 30, + plld_foam_search_speed: float = 10.0, + dispense_back_plld_volume: Optional[float] = None, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, + move_channels_to_safe_pos_after: bool = False, + ) -> Tuple[float, float]: + """Probe the liquid surface Z-height using pressure-based LLD (pLLD). + + Ensures a tip is mounted, reads the tip length, converts tip-referenced positions + to head-space coordinates, then delegates to :meth:`search_z_using_plld`. + + All positions in args are in the *tip-referenced* coordinate system. Returned + positions are also in tip-space. + + Args: + lowest_immers_pos: Lowest allowed search position in mm (tip-referenced). + start_pos_search: Start position in mm (tip-referenced). If None, highest safe. + channel_speed_above_start_pos_search: Z speed above the start position (mm/s). + channel_speed: Z search speed (mm/s). + channel_acceleration: Z acceleration (mm/s^2). + z_drive_current_limit: Z drive current limit. + tip_has_filter: Whether a filter tip is mounted. + dispense_drive_speed: Dispense drive speed (mm/s). + dispense_drive_acceleration: Dispense drive acceleration (mm/s^2). + dispense_drive_max_speed: Dispense drive max speed (mm/s). + dispense_drive_current_limit: Dispense drive current limit. + plld_detection_edge: Pressure detection edge threshold. + plld_detection_drop: Pressure detection drop threshold. + clld_verification: Activate cLLD sensing concurrently. + clld_detection_edge: Capacitive detection edge threshold. + clld_detection_drop: Capacitive detection drop threshold. + max_delta_plld_clld: Max delta between pLLD and cLLD detections (mm). + plld_mode: Pressure-detection sub-mode. + plld_foam_detection_drop: Foam detection drop threshold. + plld_foam_detection_edge_tolerance: Foam detection edge tolerance. + plld_foam_ad_values: Foam AD values. + plld_foam_search_speed: Foam search speed (mm/s). + dispense_back_plld_volume: Dispense-back volume after detection (uL). + post_detection_trajectory: Post-detection movement pattern selector. + post_detection_dist: Post-detection movement distance (mm). + move_channels_to_safe_pos_after: Move all channels to Z-safe after probing. + + Returns: + Two z-coordinates (mm) in tip-space: + - PressureLLDMode.LIQUID: ``(liquid_level_pos, 0.0)`` + - PressureLLDMode.FOAM: ``(first_detection_pos, liquid_level_pos)`` + """ + assert self.backend is not None, "backend reference required for plld_probe_z_height" + + if plld_mode is None: + plld_mode = PressureLLDMode.LIQUID + + tip_presence = await self.backend.request_tip_presence() + if not tip_presence[self.index]: + raise RuntimeError(f"No tip mounted on channel {self.index}") + + tip_len = await self.request_tip_length() + safe_tip_top_z_pos = MAXIMUM_CHANNEL_Z_POSITION - tip_len + DEFAULT_TIP_FITTING_DEPTH + + if start_pos_search is None: + start_pos_search = safe_tip_top_z_pos + + if lowest_immers_pos < MINIMUM_CHANNEL_Z_POSITION: + raise ValueError( + f"lowest_immers_pos must be at least {MINIMUM_CHANNEL_Z_POSITION} mm " + f"but is {lowest_immers_pos} mm" + ) + + # Convert tip-space to head-space + lowest_immers_pos_head_space = lowest_immers_pos + tip_len - DEFAULT_TIP_FITTING_DEPTH + channel_head_start_pos = round(start_pos_search + tip_len - DEFAULT_TIP_FITTING_DEPTH, 2) + + if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): + raise ValueError( + f"Start position of LLD search must be between " + f"{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" + ) + + try: + resp_probe_mm = await self.search_z_using_plld( + 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_to_z_safety() + raise + + # Convert head-space response to tip-space + if plld_mode == PressureLLDMode.FOAM: + resp_tip_mm = ( + round(resp_probe_mm[0] - tip_len + DEFAULT_TIP_FITTING_DEPTH, 2), + round(resp_probe_mm[1] - tip_len + DEFAULT_TIP_FITTING_DEPTH, 2), + ) + else: + resp_tip_mm = ( + round(resp_probe_mm[0] - tip_len + DEFAULT_TIP_FITTING_DEPTH, 2), + 0.0, + ) + + if move_channels_to_safe_pos_after: + await self.move_to_z_safety() + + return resp_tip_mm + + async def ztouch_probe_z_height( + self, + tip_len: Optional[float] = None, + lowest_immers_pos: float = 99.98, + start_pos_search: Optional[float] = None, + channel_speed: float = 10.0, + channel_acceleration: float = 800.0, + channel_speed_upwards: float = 125.0, + detection_limiter_in_PWM: int = 1, + push_down_force_in_PWM: int = 0, + post_detection_dist: float = 2.0, + move_channels_to_safe_pos_after: bool = False, + ) -> float: + """Probe the Z-height using z-touch-off (controlled z-drive crash detection). + + Args: + tip_len: Override the tip length (mm). If None, measured automatically. + lowest_immers_pos: Lowest allowed search position in mm (tip-referenced). + start_pos_search: Start position in mm (tip-referenced). If None, highest safe. + channel_speed: Search speed downward in mm/s. + channel_acceleration: Acceleration in mm/s^2. + channel_speed_upwards: Retraction speed in mm/s. + detection_limiter_in_PWM: Offset PWM limiter value for searching (0-125). + push_down_force_in_PWM: Offset PWM value for push down force (0-125). + post_detection_dist: Distance to retract after detection in mm. + move_channels_to_safe_pos_after: Move all channels to Z-safe after probing. + + Returns: + The detected Z-height in mm (tip-referenced). + + Raises: + ValueError: If the channel firmware predates 2022 (z-touch not supported). + """ + assert self.backend is not None, "backend reference required for ztouch_probe_z_height" + + version = await self.request_firmware_version() + if version.year < 2022: + raise ValueError( + "Z-touch probing is not supported for PIP versions predating 2022, " + f"found version '{version}'" + ) + + if tip_len is None: + tip_len = await self.request_tip_length() + + if start_pos_search is None: + start_pos_search = MAXIMUM_CHANNEL_Z_POSITION - tip_len + DEFAULT_TIP_FITTING_DEPTH + + tip_len_used_in_increments = _mm_to_z_inc(tip_len - DEFAULT_TIP_FITTING_DEPTH) + channel_head_start_pos = start_pos_search + tip_len - DEFAULT_TIP_FITTING_DEPTH + lowest_immers_pos_head_space = lowest_immers_pos + tip_len - DEFAULT_TIP_FITTING_DEPTH + safe_head_bottom_z_pos = MINIMUM_CHANNEL_Z_POSITION + tip_len - DEFAULT_TIP_FITTING_DEPTH + safe_head_top_z_pos = MAXIMUM_CHANNEL_Z_POSITION + + lowest_immers_pos_increments = _mm_to_z_inc(lowest_immers_pos_head_space) + start_pos_search_increments = _mm_to_z_inc(channel_head_start_pos) + channel_speed_increments = _mm_to_z_inc(channel_speed) + channel_acceleration_thousand_increments = _mm_to_z_inc(channel_acceleration / 1000) + channel_speed_upwards_increments = _mm_to_z_inc(channel_speed_upwards) + + if not (0 <= self.index <= 15): + raise ValueError(f"channel index must be between 0 and 15, is {self.index}") + if not (20 <= tip_len <= 120): + raise ValueError(f"Total tip length must be between 20 and 120, is {tip_len}") + if not (9320 <= lowest_immers_pos_increments <= 31200): + raise ValueError( + f"Lowest immersion position must be between 99.98 and 334.7 mm, is {lowest_immers_pos} mm" + ) + if not (safe_head_bottom_z_pos <= channel_head_start_pos <= safe_head_top_z_pos): + raise ValueError( + f"Start position of search must be between " + f"{safe_head_bottom_z_pos} and {safe_head_top_z_pos} mm, " + f"is {channel_head_start_pos} mm" + ) + if not (20 <= channel_speed_increments <= 15000): + raise ValueError( + f"Z-touch search speed must be between " + f"{_z_inc_to_mm(20)} and {_z_inc_to_mm(15000)} mm/s, is {channel_speed} mm/s" + ) + if not (5 <= channel_acceleration_thousand_increments <= 150): + raise ValueError( + f"Channel acceleration must be between " + f"{_z_inc_to_mm(5000)} and {_z_inc_to_mm(150000)} mm/s^2, " + f"is {channel_acceleration} mm/s^2" + ) + if not (20 <= channel_speed_upwards_increments <= 15000): + raise ValueError( + f"Channel retraction speed must be between " + f"{_z_inc_to_mm(20)} and {_z_inc_to_mm(15000)} mm/s, " + f"is {channel_speed_upwards} mm/s" + ) + if not (0 <= detection_limiter_in_PWM <= 125): + raise ValueError("Detection limiter value must be between 0 and 125 PWM.") + if not (0 <= push_down_force_in_PWM <= 125): + raise ValueError("Push down force must be between 0 and 125 PWM values") + if not (0 <= post_detection_dist <= 245): + raise ValueError( + f"Post detection distance must be between 0 and 245 mm, is {post_detection_dist}" + ) + + ztouch_probed_z_height = await self.send_command( + module=self.module_id, + command="ZH", + zb=f"{start_pos_search_increments:05}", + za=f"{lowest_immers_pos_increments:05}", + zv=f"{channel_speed_upwards_increments:05}", + zr=f"{channel_acceleration_thousand_increments:03}", + zu=f"{channel_speed_increments:05}", + cg=f"{detection_limiter_in_PWM:03}", + cf=f"{push_down_force_in_PWM:03}", + fmt="rz#####", + ) + + result_in_mm = _z_inc_to_mm(ztouch_probed_z_height["rz"] - tip_len_used_in_increments) + + if post_detection_dist != 0: + await self.move_tool_z(result_in_mm + post_detection_dist) + + if move_channels_to_safe_pos_after: + await self.move_to_z_safety() + + return float(result_in_mm) + + async def clld_probe_y_position( + self, + probing_direction: Literal["forward", "backward"], + start_pos_search: Optional[float] = None, + end_pos_search: Optional[float] = None, + channel_speed: float = 10.0, + channel_acceleration_int: Literal[1, 2, 3, 4] = 4, + detection_edge: int = 10, + current_limit_int: Literal[1, 2, 3, 4, 5, 6, 7] = 7, + post_detection_dist: float = 2.0, + tip_bottom_diameter: float = 1.2, + ) -> float: + """Probe the y-position of a conductive material using cLLD. + + Moves this channel along the y-axis to detect a conductive surface. Safety checks + prevent collisions with adjacent channels. After detection, the channel is retracted + by ``post_detection_dist``. + + Args: + probing_direction: ``"forward"`` (decreasing y) or ``"backward"`` (increasing y). + start_pos_search: Initial y-position for the search (mm). If None, uses current. + end_pos_search: Final y-position for the search (mm). If None, uses safe limit. + channel_speed: Movement speed during probing (mm/s). + channel_acceleration_int: Acceleration ramp [1-4] (* 5000 steps/s^2). + detection_edge: Edge steepness for capacitive detection (0-1023). + current_limit_int: Current limit [1-7]. + post_detection_dist: Retraction distance after detection (mm). + tip_bottom_diameter: Effective tip bottom diameter (mm). + + Returns: + Corrected y-position of the detected material boundary in mm. + """ + assert self.backend is not None, "backend reference required for clld_probe_y_position" + + if probing_direction not in ("forward", "backward"): + raise ValueError( + f"Probing direction must be either 'forward' or 'backward', is {probing_direction}." + ) + + # Anti-channel-crash: determine safe y bounds + assert self.driver.extended_conf is not None + if self.index > 0: + adj_upper_y = await self.backend.channels[self.index - 1].request_y_pos() + max_safe_upper_y_pos = adj_upper_y - self.driver._min_spacing_between( + self.index, self.index - 1 + ) + else: + max_safe_upper_y_pos = self.driver.extended_conf.pip_maximal_y_position + + if self.index < (self.backend.num_channels - 1): + adj_lower_y = await self.backend.channels[self.index + 1].request_y_pos() + max_safe_lower_y_pos = adj_lower_y + self.driver._min_spacing_between( + self.index, self.index + 1 + ) + else: + max_safe_lower_y_pos = self.driver.extended_conf.left_arm_min_y_position + + # Validate and optionally move to start position + if start_pos_search is not None: + if not (max_safe_lower_y_pos <= start_pos_search <= max_safe_upper_y_pos): + raise ValueError( + f"Start position for y search must be between " + f"{max_safe_lower_y_pos} and {max_safe_upper_y_pos} mm, " + f"is {start_pos_search} mm. Otherwise channel will crash." + ) + await self.move_y(start_pos_search) + + if end_pos_search is not None: + if not (max_safe_lower_y_pos <= end_pos_search <= max_safe_upper_y_pos): + raise ValueError( + f"End position for y search must be between " + f"{max_safe_lower_y_pos} and {max_safe_upper_y_pos} mm, " + f"is {end_pos_search} mm. Otherwise channel will crash." + ) + + # Set safe search end based on direction + current_channel_y_pos = await self.request_y_pos() + 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 {self.index} cannot move backward: " + f"End position = {max_y_search_pos} < current position = {current_channel_y_pos}" + f"\nDid you mean to move forward?" + ) + else: # 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 {self.index} cannot move forward: " + f"End position = {max_y_search_pos} > current position = {current_channel_y_pos}" + f"\nDid you mean to move backward?" + ) + + # Convert to increments + max_y_search_pos_increments = _mm_to_y_inc(max_y_search_pos) + channel_speed_increments = _mm_to_y_inc(channel_speed) + + if not (0 <= max_y_search_pos_increments <= 13714): + raise ValueError( + f"Maximum y search position must be between 0 and " + f"{_y_inc_to_mm(13714)} mm, is {max_y_search_pos} mm" + ) + if not (20 <= channel_speed_increments <= 8000): + raise ValueError( + f"LLD search speed must be between {_y_inc_to_mm(20)} and " + f"{_y_inc_to_mm(8000)} mm/s, is {channel_speed} mm/s" + ) + if channel_acceleration_int not in (1, 2, 3, 4): + raise ValueError( + f"Channel acceleration must be in [1, 2, 3, 4], is {channel_acceleration_int}" + ) + if not (0 <= detection_edge <= 1023): + raise ValueError("Edge steepness must be between 0 and 1023") + if not (0 <= current_limit_int <= 7): + raise ValueError(f"Current limit must be between 0 and 7, is {current_limit_int}") + + # Send Px:YL command + await self.send_command( + module=self.module_id, + command="YL", + ya=f"{max_y_search_pos_increments:05}", + gt=f"{detection_edge:04}", + gl=f"{0:04}", # always 0 to measure y-pos + yv=f"{channel_speed_increments:04}", + yr=f"{channel_acceleration_int}", + yw=f"{current_limit_int}", + read_timeout=120, + ) + + detected_material_y_pos = await self.request_y_pos() + + # Post-detection retraction with anti-crash + if probing_direction == "backward": + if self.index < self.backend.num_channels - 1: + min_y = await self.backend.channels[ + self.index + 1 + ].request_y_pos() + self.driver._min_spacing_between(self.index, self.index + 1) + else: + min_y = self.driver.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: # forward + if self.index > 0: + max_y = await self.backend.channels[ + self.index - 1 + ].request_y_pos() - self.driver._min_spacing_between(self.index, self.index - 1) + else: + max_y = self.driver.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_y(move_target) + + # Correct for tip geometry + if probing_direction == "backward": + material_y_pos = detected_material_y_pos + tip_bottom_diameter / 2 + else: + material_y_pos = detected_material_y_pos - tip_bottom_diameter / 2 + + return round(material_y_pos, 1) diff --git a/pylabrobot/hamilton/liquid_handlers/star/star.py b/pylabrobot/hamilton/liquid_handlers/star/star.py new file mode 100644 index 00000000000..f7c8de6b92d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/star.py @@ -0,0 +1,150 @@ +"""STAR device: wires STARDriver backends to PIP/Head96/iSWAP capability frontends.""" + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +from pylabrobot.capabilities.arms.arm import GripperArm +from pylabrobot.capabilities.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.head96 import Head96 +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate +from pylabrobot.resources.hamilton import HamiltonDeck, STARDeck, STARLetDeck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers, HamiltonSTARDeck + +from .chatterbox import STARChatterboxDriver +from .core import CoreGripper +from .driver import STARDriver + + +class _HamiltonSTAR(Device): + """Base class for Hamilton STAR/STARLet liquid handlers. + + Wires capability frontends (PIP, Head96, iSWAP) to the STARDriver's backends + after hardware discovery during setup(). + """ + + def __init__(self, deck: HamiltonDeck, chatterbox: bool = False): + driver = STARChatterboxDriver(deck=deck) if chatterbox else STARDriver(deck=deck) + super().__init__(driver=driver) + self.driver: STARDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + self.head96: Optional[Head96] = None # set in setup() if installed + self.iswap: Optional[OrientableArm] = None # set in setup() if installed + + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.driver.setup(backend_params=backend_params) + + # PIP is always present. + self.pip = PIP(backend=self.driver.pip, deck=self.deck) + self._capabilities = [self.pip] + + # Head96 only if the hardware has a 96-head installed. + if self.driver.head96 is not None: + self.head96 = Head96(backend=self.driver.head96, deck=self.deck) + self._capabilities.append(self.head96) + + # iSWAP only if installed. + if self.driver.iswap is not None: + self.iswap = OrientableArm(backend=self.driver.iswap, reference_resource=self.deck) + self._capabilities.append(self.iswap) + + # Matches legacy: autoload runs in parallel with arm modules. + # Arm modules run sequentially (pip → iswap → head96) because they share the left x-arm. + async def setup_arm_modules(): + await self.pip._on_setup() + if self.iswap is not None: + await self.iswap._on_setup() + if self.head96 is not None: + await self.head96._on_setup() + + async def setup_autoload(): + if self.driver.autoload is not None: + await self.driver.autoload._on_setup() + + await asyncio.gather(setup_autoload(), setup_arm_modules()) + self._setup_finished = True + + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + self.head96 = None + self.iswap = None + + # -- CoRe grippers --------------------------------------------------------- + + @asynccontextmanager + async def core_grippers( + self, + front_channel: int = 7, + front_offset: Coordinate = Coordinate.zero(), + back_offset: Coordinate = Coordinate.zero(), + traversal_height: float = 280.0, + ) -> AsyncIterator[GripperArm]: + """Context manager that picks up CoRe gripper tools on enter and returns them on exit. + + Usage:: + + async with star.core_grippers(front_channel=7) as arm: + await arm.move_resource(plate, destination) + """ + + # Park iSWAP first if it's out — the arms share the X drive. + if self.iswap is not None and not self.iswap.backend.parked: # type: ignore[attr-defined] + await self.iswap.backend.park() + + core_grippers_resource = self.deck.get_resource("core_grippers") + if not isinstance(core_grippers_resource, HamiltonCoreGrippers): + raise TypeError("core_grippers resource must be HamiltonCoreGrippers") + + back_channel = front_channel - 1 + loc = core_grippers_resource.get_absolute_location() + xs = loc.x + front_offset.x + back_y = int(loc.y + core_grippers_resource.back_channel_y_center + back_offset.y) + front_y = int(loc.y + core_grippers_resource.front_channel_y_center + front_offset.y) + z_offset = front_offset.z + + await self.driver.pick_up_core_gripper_tools( + x_position=xs, + back_channel_y=back_y, + front_channel_y=front_y, + back_channel=back_channel, + front_channel=front_channel, + begin_z=235.0 + z_offset, + end_z=225.0 + z_offset, + traversal_height=traversal_height, + ) + + backend = CoreGripper(driver=self.driver) + arm = GripperArm(backend=backend, reference_resource=self.deck, grip_axis="y") + + try: + yield arm + finally: + await self.driver.return_core_gripper_tools( + x_position=xs, + back_channel_y=back_y, + front_channel_y=front_y, + begin_z=215.0 + z_offset, + end_z=205.0 + z_offset, + traversal_height=traversal_height, + ) + + +class STAR(_HamiltonSTAR): + """Hamilton STAR liquid handler.""" + + def __init__(self, chatterbox: bool = False): + super().__init__(deck=STARDeck(), chatterbox=chatterbox) + + +class STARLet(_HamiltonSTAR): + """Hamilton STARLet liquid handler.""" + + def __init__(self, chatterbox: bool = False): + super().__init__(deck=STARLetDeck(), chatterbox=chatterbox) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/star/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py new file mode 100644 index 00000000000..555169adb4b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py @@ -0,0 +1,379 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload + + +class TestAutoloadCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARAutoload methods produce the correct firmware commands.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.autoload = STARAutoload(driver=self.mock_driver, instrument_size_slots=54) + + # -- initialization -------------------------------------------------------- + + async def test_on_setup_not_initialized(self): + self.mock_driver.send_command.return_value = {"qw": 0} + await self.autoload._on_setup() + calls = self.mock_driver.send_command.call_args_list + # Should check init status, initialize, then park (safe z + park) + self.assertEqual(calls[0].kwargs, {"module": "I0", "command": "QW", "fmt": "qw#"}) + self.assertEqual(calls[1].kwargs, {"module": "C0", "command": "II"}) + + async def test_on_setup_already_initialized(self): + self.mock_driver.send_command.return_value = {"qw": 1} + await self.autoload._on_setup() + calls = self.mock_driver.send_command.call_args_list + # Should check init status, skip init, then park + self.assertEqual(calls[0].kwargs, {"module": "I0", "command": "QW", "fmt": "qw#"}) + + async def test_request_initialization_status_true(self): + self.mock_driver.send_command.return_value = {"qw": 1} + result = await self.autoload.request_initialization_status() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with(module="I0", command="QW", fmt="qw#") + + async def test_request_initialization_status_false(self): + self.mock_driver.send_command.return_value = {"qw": 0} + result = await self.autoload.request_initialization_status() + self.assertFalse(result) + + # -- z-position safety ----------------------------------------------------- + + async def test_move_to_safe_z_position(self): + await self.autoload.move_to_safe_z_position() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="IV") + + # -- position queries ------------------------------------------------------ + + async def test_request_track(self): + self.mock_driver.send_command.return_value = {"qa": 12} + result = await self.autoload.request_track() + self.assertEqual(result, 12) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="QA", fmt="qa##") + + async def test_request_type_1d(self): + self.mock_driver.send_command.return_value = {"cq": 0} + result = await self.autoload.request_type() + self.assertEqual(result, "ML-STAR with 1D Barcode Scanner") + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CQ", fmt="cq#") + + async def test_request_type_2d(self): + self.mock_driver.send_command.return_value = {"cq": 2} + result = await self.autoload.request_type() + self.assertEqual(result, "ML-STAR with 2D Barcode Scanner") + + async def test_request_type_unknown(self): + self.mock_driver.send_command.return_value = {"cq": 9} + result = await self.autoload.request_type() + self.assertEqual(result, "9") + + # -- carrier sensing ------------------------------------------------------- + + async def test_decode_hex_bitmask_empty(self): + self.assertEqual(STARAutoload._decode_hex_bitmask_to_track_list("0000"), []) + + async def test_decode_hex_bitmask_single(self): + # 0x01 = bit 0 set = slot 1 + self.assertEqual(STARAutoload._decode_hex_bitmask_to_track_list("01"), [1]) + + async def test_decode_hex_bitmask_multiple(self): + # 0x05 = bits 0 and 2 = slots 1 and 3 + self.assertEqual(STARAutoload._decode_hex_bitmask_to_track_list("05"), [1, 3]) + + async def test_decode_hex_bitmask_invalid(self): + with self.assertRaises(ValueError): + STARAutoload._decode_hex_bitmask_to_track_list("ZZ") + + async def test_request_presence_of_carriers_on_deck(self): + self.mock_driver.send_command.return_value = "C0RCid0001ce0005" + result = await self.autoload.request_presence_of_carriers_on_deck() + self.assertEqual(result, [1, 3]) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="RC") + + async def test_request_presence_of_carriers_on_loading_tray(self): + self.mock_driver.send_command.return_value = "C0CSid0001cd03" + result = await self.autoload.request_presence_of_carriers_on_loading_tray() + self.assertEqual(result, [1, 2]) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CS") + + async def test_request_presence_of_carriers_on_loading_tray_missing_cd(self): + self.mock_driver.send_command.return_value = "C0CSid0001xx00" + with self.assertRaises(ValueError): + await self.autoload.request_presence_of_carriers_on_loading_tray() + + async def test_request_presence_of_single_carrier_present(self): + self.mock_driver.send_command.return_value = {"ct": 1} + result = await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=10) + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CT", fmt="ct#", cp="10" + ) + + async def test_request_presence_of_single_carrier_absent(self): + self.mock_driver.send_command.return_value = {"ct": 0} + result = await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=5) + self.assertFalse(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CT", fmt="ct#", cp="05" + ) + + async def test_request_presence_of_single_carrier_invalid_track(self): + with self.assertRaises(ValueError): + await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=0) + with self.assertRaises(ValueError): + await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=55) + + # -- movement commands ----------------------------------------------------- + + async def test_move_to_track(self): + await self.autoload.move_to_track(track=12) + calls = self.mock_driver.send_command.call_args_list + # First call: move_to_safe_z_position (C0:IV) + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "IV"}) + # Second call: I0:XP + self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "12"}) + + async def test_move_to_track_invalid(self): + with self.assertRaises(ValueError): + await self.autoload.move_to_track(track=0) + with self.assertRaises(ValueError): + await self.autoload.move_to_track(track=55) + + async def test_park(self): + await self.autoload.park() + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "IV"}) + self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "54"}) + + async def test_park_custom_slots(self): + self.autoload._instrument_size_slots = 30 + await self.autoload.park() + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "30"}) + + # -- belt operations ------------------------------------------------------- + + async def test_take_carrier_out_to_belt(self): + # Carrier not on tray -> should proceed with CN command + self.mock_driver.send_command.side_effect = [ + {"ct": 0}, # presence check returns absent + None, # CN command + ] + await self.autoload.take_carrier_out_to_belt(carrier_end_rail=10) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "CT", "fmt": "ct#", "cp": "10"}) + self.assertEqual(calls[1].kwargs, {"module": "C0", "command": "CN", "cp": "10"}) + + async def test_take_carrier_out_to_belt_already_on_tray(self): + self.mock_driver.send_command.return_value = {"ct": 1} + with self.assertRaises(ValueError, msg="already on the loading tray"): + await self.autoload.take_carrier_out_to_belt(carrier_end_rail=10) + + async def test_unload_carrier_after_barcode_scanning(self): + await self.autoload.unload_carrier_after_barcode_scanning() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CA") + + # -- barcode commands ------------------------------------------------------ + + async def test_set_1d_barcode_type(self): + await self.autoload.set_1d_barcode_type("Code 39") + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CB", bt="04") + self.assertEqual(self.autoload._default_1d_symbology, "Code 39") + + async def test_set_1d_barcode_type_default(self): + await self.autoload.set_1d_barcode_type(None) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="CB", + bt="02", # Code 128 default + ) + + async def test_load_carrier_from_tray_and_scan_carrier_barcode(self): + self.mock_driver.send_command.return_value = "C0CIid0001bb/ABC123" + result = await self.autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=10, + carrier_barcode_reading=True, + ) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result.data, "ABC123") + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="CI", + cp="10", + bi="0043", + bw="380", + co="0960", + cv="1281", + ) + + async def test_load_carrier_from_tray_no_barcode_reading(self): + self.mock_driver.send_command.return_value = "C0CIid0001" + result = await self.autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=10, + carrier_barcode_reading=False, + ) + self.assertIsNone(result) + + # -- high-level load / unload ---------------------------------------------- + + async def test_unload_carrier(self): + self.mock_driver.send_command.side_effect = [ + "C0CRid0001", # CR command + None, # safe z + None, # park XP + ] + await self.autoload.unload_carrier(carrier_end_rail=10, park_autoload_after=True) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "CR", "cp": "10"}) + + async def test_unload_carrier_no_park(self): + self.mock_driver.send_command.return_value = "C0CRid0001" + await self.autoload.unload_carrier(carrier_end_rail=10, park_autoload_after=False) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CR", cp="10") + + async def test_unload_carrier_invalid_rail(self): + with self.assertRaises(ValueError): + await self.autoload.unload_carrier(carrier_end_rail=0) + with self.assertRaises(ValueError): + await self.autoload.unload_carrier(carrier_end_rail=55) + + # -- LED / monitoring ------------------------------------------------------ + + async def test_set_loading_indicators(self): + bit_pattern = [True] + [False] * 53 + blink_pattern = [False] * 53 + [True] + await self.autoload.set_loading_indicators(bit_pattern, blink_pattern) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="CP", + cl="20000000000000", + cb="00000000000001", + ) + + async def test_set_loading_indicators_invalid_length(self): + with self.assertRaises(ValueError): + await self.autoload.set_loading_indicators([True] * 10, [False] * 10) + + async def test_set_carrier_monitoring(self): + await self.autoload.set_carrier_monitoring(should_monitor=True) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CU", cu=True) + + async def test_load_carrier_from_belt_no_barcode(self): + self.mock_driver.send_command.side_effect = [ + "C0CLid0001", # CL command + None, # safe z (park) + None, # park XP + ] + result = await self.autoload.load_carrier_from_belt( + barcode_reading=False, + park_autoload_after=True, + ) + self.assertEqual(result, {}) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs["module"], "C0") + self.assertEqual(calls[0].kwargs["command"], "CL") + self.assertEqual(calls[0].kwargs["bd"], "0") # vertical when no barcode + self.assertEqual(calls[0].kwargs["cn"], "00") # no scanning + + async def test_load_carrier_from_belt_with_barcode(self): + """Test loading a carrier from the belt with barcode scanning enabled.""" + self.mock_driver.send_command.side_effect = [ + None, # CB command (set_1d_barcode_type) + "C0CLid0001bb/ABC123/DEF456/00/GHI789/JKL012", # CL command with barcodes + None, # safe z (park) + None, # park XP + ] + result = await self.autoload.load_carrier_from_belt( + barcode_reading=True, + barcode_reading_direction="horizontal", + barcode_symbology="Code 128 (Subset B and C)", + no_container_per_carrier=5, + park_autoload_after=True, + ) + # Verify set_1d_barcode_type was called (CB command) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs["module"], "C0") + self.assertEqual(calls[0].kwargs["command"], "CB") + # Verify CL command + self.assertEqual(calls[1].kwargs["module"], "C0") + self.assertEqual(calls[1].kwargs["command"], "CL") + self.assertEqual(calls[1].kwargs["bd"], "1") # horizontal + # Verify returned barcode dict + self.assertEqual(len(result), 5) + assert result[0] is not None + self.assertEqual(result[0].data, "ABC123") + assert result[1] is not None + self.assertEqual(result[1].data, "DEF456") + self.assertIsNone(result[2]) # "00" means no barcode + assert result[3] is not None + self.assertEqual(result[3].data, "GHI789") + assert result[4] is not None + self.assertEqual(result[4].data, "JKL012") + + async def test_load_carrier(self): + """Test high-level load_carrier orchestration.""" + self.mock_driver.send_command.side_effect = [ + {"ct": 1}, # CT: presence check returns True + "C0CIid0001", # CI: carrier barcode scan (no barcode reading) + "C0CLid0001", # CL: belt load (no barcode reading) + None, # IV: safe z (park) + None, # XP: park + ] + result = await self.autoload.load_carrier( + carrier_end_rail=10, + barcode_reading=False, + carrier_barcode_reading=False, + ) + self.assertIsNone(result["carrier_barcode"]) + self.assertIsNone(result["container_barcodes"]) + # Verify CT was called for presence check + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs["command"], "CT") + # Verify CI was called + self.assertEqual(calls[1].kwargs["command"], "CI") + # Verify CL was called + self.assertEqual(calls[2].kwargs["command"], "CL") + + async def test_take_carrier_out_to_belt_error_recovery(self): + """Test that move_to_safe_z_position (IV) is called before RuntimeError on CN failure.""" + self.mock_driver.send_command.side_effect = [ + {"ct": 0}, # CT: carrier NOT present on tray + RuntimeError("CN firmware error"), # CN: raises exception + None, # IV: move_to_safe_z_position + ] + with self.assertRaises(RuntimeError): + await self.autoload.take_carrier_out_to_belt(carrier_end_rail=10) + # Verify move_to_safe_z_position (IV) was called after the CN error + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[2].kwargs["command"], "IV") + + async def test_unload_carrier_after_barcode_scanning_error_recovery(self): + """Test that move_to_safe_z_position (IV) is called before RuntimeError on CA failure.""" + self.mock_driver.send_command.side_effect = [ + RuntimeError("CA firmware error"), # CA: raises exception + None, # IV: move_to_safe_z_position + ] + with self.assertRaises(RuntimeError): + await self.autoload.unload_carrier_after_barcode_scanning() + # Verify move_to_safe_z_position (IV) was called + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[1].kwargs["command"], "IV") + + async def test_load_carrier_from_tray_and_scan_carrier_barcode_error_recovery(self): + """Test that move_to_safe_z_position (IV) is called before RuntimeError on CI failure.""" + self.mock_driver.send_command.side_effect = [ + RuntimeError("CI firmware error"), # CI: raises exception + None, # IV: move_to_safe_z_position + ] + with self.assertRaises(RuntimeError): + await self.autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=10, + carrier_barcode_reading=True, + ) + # Verify move_to_safe_z_position (IV) was called + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[1].kwargs["command"], "IV") diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/core_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/core_tests.py new file mode 100644 index 00000000000..fd386a489a2 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/core_tests.py @@ -0,0 +1,166 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.core import CoreGripper +from pylabrobot.resources import Coordinate + + +class TestCoreGripperCommands(unittest.IsolatedAsyncioTestCase): + """Test that CoreGripper methods produce the exact same firmware commands as the legacy + STARBackend equivalents.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.core = CoreGripper(driver=self.mock_driver) + + async def test_pick_up_at_location(self): + """ZP with default params, plate width 86mm at (347.9, 114.2, 187.4).""" + await self.core.pick_up_at_location( + location=Coordinate(347.9, 114.2, 187.4), + resource_width=86.0, + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZP", + xs="03479", + xd=0, + yj="1142", + yv="0050", + zj="1874", + zy="0500", + yo="0890", + yg="0830", + yw="15", + th="2800", + te="2800", + ) + + async def test_pick_up_at_location_custom_params(self): + """ZP with custom grip strength and speeds.""" + await self.core.pick_up_at_location( + location=Coordinate(500.0, 200.0, 150.0), + resource_width=127.76, + backend_params=CoreGripper.PickUpParams( + grip_strength=20, + y_gripping_speed=10.0, + z_speed=80.0, + minimum_traverse_height=300.0, + z_position_at_end=290.0, + ), + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZP", + xs="05000", + xd=0, + yj="2000", + yv="0100", + zj="1500", + zy="0800", + yo="1308", + yg="1248", + yw="20", + th="3000", + te="2900", + ) + + async def test_drop_at_location(self): + """ZR with default params, plate width 86mm at (347.9, 306.2, 187.4).""" + await self.core.drop_at_location( + location=Coordinate(347.9, 306.2, 187.4), + resource_width=86.0, + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZR", + xs="03479", + xd=0, + yj="3062", + zj="1874", + zi="000", + zy="0500", + yo="0890", + th="2800", + te="2800", + ) + + async def test_drop_at_location_custom_params(self): + """ZR with custom press distance.""" + await self.core.drop_at_location( + location=Coordinate(500.0, 200.0, 150.0), + resource_width=86.0, + backend_params=CoreGripper.DropParams( + z_press_on_distance=5.0, + z_speed=30.0, + minimum_traverse_height=300.0, + z_position_at_end=290.0, + ), + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZR", + xs="05000", + xd=0, + yj="2000", + zj="1500", + zi="050", + zy="0300", + yo="0890", + th="3000", + te="2900", + ) + + async def test_move_to_location(self): + """ZM with default params at (500.0, 200.0, 150.0).""" + await self.core.move_to_location( + location=Coordinate(500.0, 200.0, 150.0), + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZM", + xs="05000", + xd=0, + xg=4, + yj="2000", + zj="1500", + zy="0500", + th="2800", + ) + + async def test_move_to_location_custom_params(self): + """ZM with custom acceleration and speed.""" + await self.core.move_to_location( + location=Coordinate(800.0, 300.0, 200.0), + backend_params=CoreGripper.MoveToLocationParams( + acceleration_index=2, + z_speed=30.0, + minimum_traverse_height=350.0, + ), + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZM", + xs="08000", + xd=0, + xg=2, + yj="3000", + zj="2000", + zy="0300", + th="3500", + ) + + async def test_open_gripper(self): + """ZO command.""" + await self.core.open_gripper(gripper_width=0) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="ZO", + ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py new file mode 100644 index 00000000000..f4c35cbe532 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py @@ -0,0 +1,64 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.cover import STARCover + + +class TestSTARCoverCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARCover methods produce the exact firmware commands expected.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.cover = STARCover(driver=self.mock_driver) + + async def test_lock(self): + await self.cover.lock() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CO") + + async def test_unlock(self): + await self.cover.unlock() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="HO") + + async def test_disable(self): + await self.cover.disable() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CD") + + async def test_enable(self): + await self.cover.enable() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CE") + + async def test_set_output(self): + await self.cover.set_output(output=1) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="OS", on=1) + + async def test_set_output_reserve(self): + await self.cover.set_output(output=2) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="OS", on=2) + + async def test_set_output_invalid(self): + with self.assertRaises(ValueError): + await self.cover.set_output(output=0) + with self.assertRaises(ValueError): + await self.cover.set_output(output=4) + + async def test_reset_output(self): + await self.cover.reset_output(output=1) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="QS", on=1, fmt="#") + + async def test_reset_output_invalid(self): + with self.assertRaises(ValueError): + await self.cover.reset_output(output=0) + with self.assertRaises(ValueError): + await self.cover.reset_output(output=4) + + async def test_is_open_true(self): + self.mock_driver.send_command.return_value = {"qc": 1} + result = await self.cover.is_open() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="QC", fmt="qc#") + + async def test_is_open_false(self): + self.mock_driver.send_command.return_value = {"qc": 0} + result = await self.cover.is_open() + self.assertFalse(result) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py new file mode 100644 index 00000000000..c2d9a173fab --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py @@ -0,0 +1,194 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend +from pylabrobot.resources import Coordinate + + +class TestiSWAPCommands(unittest.IsolatedAsyncioTestCase): + """Test that iSWAP methods produce the exact same firmware commands as the legacy + STARBackend equivalents.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.iswap = iSWAPBackend(driver=self.mock_driver) + + async def test_pick_up_at_location(self): + """C0PPid0001xs03479xd0yj1142yd0zj1874zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0""" + await self.iswap.pick_up_at_location( + location=Coordinate(347.9, 114.2, 187.4), + direction=0.0, + resource_width=127.76, + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="PP", + xs="03479", + xd=0, + yj="1142", + yd=0, + zj="1874", + zd=0, + gr=1, + th="2800", + te="2800", + gw=4, + go="1308", + gb="1245", + gt="20", + ga=0, + gc=False, + ) + + async def test_pick_up_grip_direction_left(self): + """C0PPid0003xs10427xd0yj3286yd0zj2063zd0gr4th2800te2800gw4go1308gb1245gt20ga0gc0""" + await self.iswap.pick_up_at_location( + location=Coordinate(1042.7, 328.6, 206.3), + direction=270.0, + resource_width=127.76, + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="PP", + xs="10427", + xd=0, + yj="3286", + yd=0, + zj="2063", + zd=0, + gr=4, + th="2800", + te="2800", + gw=4, + go="1308", + gb="1245", + gt="20", + ga=0, + gc=False, + ) + + async def test_drop_at_location(self): + """C0PRid0002xs03479xd0yj3062yd0zj1874zd0th2800te2800gr1go1308ga0gc0""" + await self.iswap.drop_at_location( + location=Coordinate(347.9, 306.2, 187.4), + direction=0.0, + resource_width=127.76, + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="PR", + xs="03479", + xd=0, + yj="3062", + yd=0, + zj="1874", + zd=0, + th="2800", + te="2800", + gr=1, + go="1308", + ga=0, + gc=False, + ) + + async def test_drop_grip_direction_left(self): + """C0PRid0002xs10427xd0yj3286yd0zj2063zd0th2800te2800gr4go1308ga0gc0""" + await self.iswap.drop_at_location( + location=Coordinate(1042.7, 328.6, 206.3), + direction=270.0, + resource_width=127.76, + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="PR", + xs="10427", + xd=0, + yj="3286", + yd=0, + zj="2063", + zd=0, + th="2800", + te="2800", + gr=4, + go="1308", + ga=0, + gc=False, + ) + + async def test_park(self): + await self.iswap.park() + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="PG", + th=2800, + ) + self.assertTrue(self.iswap.parked) + + async def test_park_custom_height(self): + await self.iswap.park(backend_params=iSWAPBackend.ParkParams(minimum_traverse_height=200.0)) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="PG", + th=2000, + ) + + async def test_open_gripper(self): + await self.iswap.open_gripper(gripper_width=130.8) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="GF", + go="1308", + ) + + async def test_close_gripper(self): + await self.iswap.close_gripper( + gripper_width=86.0, + backend_params=iSWAPBackend.CloseGripperParams(grip_strength=5, plate_width_tolerance=2.0), + ) + + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="GC", + gw=5, + gb="0860", + gt="20", + ) + + async def test_is_gripper_closed(self): + self.mock_driver.send_command.return_value = {"ph": 1} + result = await self.iswap.is_gripper_closed() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="QP", + fmt="ph#", + ) + + async def test_is_gripper_open(self): + self.mock_driver.send_command.return_value = {"ph": 0} + result = await self.iswap.is_gripper_closed() + self.assertFalse(result) + + async def test_parked_state_after_pick(self): + await self.iswap.pick_up_at_location( + location=Coordinate(100, 100, 100), + direction=0.0, + resource_width=80.0, + ) + self.assertFalse(self.iswap.parked) + + async def test_parked_state_after_drop(self): + await self.iswap.drop_at_location( + location=Coordinate(100, 100, 100), + direction=0.0, + resource_width=80.0, + ) + self.assertFalse(self.iswap.parked) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py new file mode 100644 index 00000000000..ff74cb454b5 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py @@ -0,0 +1,198 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.wash_station import STARWashStation + + +class TestSTARWashStationCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARWashStation methods produce the exact firmware commands expected.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.ws = STARWashStation(driver=self.mock_driver) + + # -- request_settings ------------------------------------------------------- + + async def test_request_settings_station_1(self): + self.mock_driver.send_command.return_value = {"et": 4} + result = await self.ws.request_settings(station=1) + self.assertEqual(result, 4) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="ET", fmt="et#", ep=1 + ) + + async def test_request_settings_station_2(self): + self.mock_driver.send_command.return_value = {"et": 0} + result = await self.ws.request_settings(station=2) + self.assertEqual(result, 0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="ET", fmt="et#", ep=2 + ) + + async def test_request_settings_station_3(self): + self.mock_driver.send_command.return_value = {"et": 5} + result = await self.ws.request_settings(station=3) + self.assertEqual(result, 5) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="ET", fmt="et#", ep=3 + ) + + async def test_request_settings_invalid_station_0(self): + with self.assertRaises(ValueError): + await self.ws.request_settings(station=0) + + async def test_request_settings_invalid_station_4(self): + with self.assertRaises(ValueError): + await self.ws.request_settings(station=4) + + # -- initialize_valves ------------------------------------------------------ + + async def test_initialize_valves_station_1(self): + await self.ws.initialize_valves(station=1) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="EJ", ep=1) + + async def test_initialize_valves_station_2(self): + await self.ws.initialize_valves(station=2) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="EJ", ep=2) + + async def test_initialize_valves_station_3(self): + await self.ws.initialize_valves(station=3) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="EJ", ep=3) + + async def test_initialize_valves_invalid_station_0(self): + with self.assertRaises(ValueError): + await self.ws.initialize_valves(station=0) + + async def test_initialize_valves_invalid_station_4(self): + with self.assertRaises(ValueError): + await self.ws.initialize_valves(station=4) + + # -- fill_chamber ----------------------------------------------------------- + + async def test_fill_chamber_defaults(self): + """Default: station=1, drain_before_refill=False, wash_fluid=1, chamber=2 -> connection 0.""" + await self.ws.fill_chamber() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=False, + ek=0, + eu="00", + wait=False, + ) + + async def test_fill_chamber_wash_fluid_1_chamber_1(self): + """wash_fluid=1, chamber=1 -> connection 1.""" + await self.ws.fill_chamber(station=1, wash_fluid=1, chamber=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=False, + ek=1, + eu="00", + wait=False, + ) + + async def test_fill_chamber_wash_fluid_2_chamber_1(self): + """wash_fluid=2, chamber=1 -> connection 2.""" + await self.ws.fill_chamber(station=2, wash_fluid=2, chamber=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=2, + ed=False, + ek=2, + eu="00", + wait=False, + ) + + async def test_fill_chamber_wash_fluid_2_chamber_2(self): + """wash_fluid=2, chamber=2 -> connection 3.""" + await self.ws.fill_chamber(station=3, wash_fluid=2, chamber=2) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=3, + ed=False, + ek=3, + eu="00", + wait=False, + ) + + async def test_fill_chamber_drain_before_refill(self): + await self.ws.fill_chamber(station=1, drain_before_refill=True, wash_fluid=1, chamber=2) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=True, + ek=0, + eu="00", + wait=False, + ) + + async def test_fill_chamber_suck_time(self): + await self.ws.fill_chamber( + station=1, + wash_fluid=1, + chamber=2, + waste_chamber_suck_time_after_sensor_change=15, + ) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=False, + ek=0, + eu="15", + wait=False, + ) + + async def test_fill_chamber_invalid_station_0(self): + with self.assertRaises(ValueError): + await self.ws.fill_chamber(station=0) + + async def test_fill_chamber_invalid_station_4(self): + with self.assertRaises(ValueError): + await self.ws.fill_chamber(station=4) + + async def test_fill_chamber_invalid_wash_fluid_0(self): + with self.assertRaises(ValueError): + await self.ws.fill_chamber(wash_fluid=0) + + async def test_fill_chamber_invalid_wash_fluid_3(self): + with self.assertRaises(ValueError): + await self.ws.fill_chamber(wash_fluid=3) + + async def test_fill_chamber_invalid_chamber_0(self): + with self.assertRaises(ValueError): + await self.ws.fill_chamber(chamber=0) + + async def test_fill_chamber_invalid_chamber_3(self): + with self.assertRaises(ValueError): + await self.ws.fill_chamber(chamber=3) + + # -- drain ------------------------------------------------------------------ + + async def test_drain_station_1(self): + await self.ws.drain(station=1) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="EL", ep=1) + + async def test_drain_station_2(self): + await self.ws.drain(station=2) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="EL", ep=2) + + async def test_drain_station_3(self): + await self.ws.drain(station=3) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="EL", ep=3) + + async def test_drain_invalid_station_0(self): + with self.assertRaises(ValueError): + await self.ws.drain(station=0) + + async def test_drain_invalid_station_4(self): + with self.assertRaises(ValueError): + await self.ws.drain(station=4) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py new file mode 100644 index 00000000000..db05e7a01d2 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py @@ -0,0 +1,155 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.x_arm import STARXArm + + +class TestSTARXArmCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARXArm methods produce the exact same firmware commands as the legacy + STARBackend equivalents, for both left and right arms.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.mock_driver.left_side_panel_installed = False + self.mock_driver.PIP_X_MIN_WITH_LEFT_SIDE_PANEL = 320.0 + self.left_arm = STARXArm(driver=self.mock_driver, side="left") + self.right_arm = STARXArm(driver=self.mock_driver, side="right") + + # -- move_to (C0:JX / C0:JS) ---------------------------------------------- + + async def test_left_move_to(self): + await self.left_arm.move_to(x_position=500.0) # 500 mm -> 05000 in 0.1mm + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JX", + xs="05000", + ) + + async def test_right_move_to(self): + await self.right_arm.move_to(x_position=500.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JS", + xs="05000", + ) + + async def test_left_move_to_default(self): + await self.left_arm.move_to() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JX", + xs="00000", + ) + + async def test_right_move_to_default(self): + await self.right_arm.move_to() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JS", + xs="00000", + ) + + # -- move_to_safe (C0:KX / C0:KR) ----------------------------------------- + + async def test_left_move_to_safe(self): + await self.left_arm.move_to_safe(x_position=1000.0) # 1000 mm -> 10000 in 0.1mm + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KX", + xs=10000, + ) + + async def test_right_move_to_safe(self): + await self.right_arm.move_to_safe(x_position=1000.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KR", + xs=10000, + ) + + async def test_left_move_to_safe_default(self): + await self.left_arm.move_to_safe() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KX", + xs=0, + ) + + async def test_right_move_to_safe_default(self): + await self.right_arm.move_to_safe() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KR", + xs=0, + ) + + # -- request_position (C0:RX / C0:QX) ------------------------------------- + + async def test_left_request_position(self): + self.mock_driver.send_command.return_value = {"rx": 15000} + result = await self.left_arm.request_position() + self.assertEqual(result, 1500.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="RX", + fmt="rx#####", + ) + + async def test_right_request_position(self): + self.mock_driver.send_command.return_value = {"rx": 15000} + result = await self.right_arm.request_position() + self.assertEqual(result, 1500.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="QX", + fmt="rx#####", + ) + + # -- last_collision_type (C0:XX / C0:XR) ----------------------------------- + + async def test_left_last_collision_type_true(self): + self.mock_driver.send_command.return_value = {"xq": 1} + result = await self.left_arm.last_collision_type() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="XX", + fmt="xq#", + ) + + async def test_left_last_collision_type_false(self): + self.mock_driver.send_command.return_value = {"xq": 0} + result = await self.left_arm.last_collision_type() + self.assertFalse(result) + + async def test_right_last_collision_type_true(self): + self.mock_driver.send_command.return_value = {"xq": 1} + result = await self.right_arm.last_collision_type() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="XR", + fmt="xq#", + ) + + async def test_right_last_collision_type_false(self): + self.mock_driver.send_command.return_value = {"xq": 0} + result = await self.right_arm.last_collision_type() + self.assertFalse(result) + + # -- assertion checks ------------------------------------------------------ + + async def test_move_to_rejects_out_of_range(self): + with self.assertRaises(ValueError): + await self.left_arm.move_to(x_position=-1) + with self.assertRaises(ValueError): + await self.left_arm.move_to(x_position=3001) + with self.assertRaises(ValueError): + await self.right_arm.move_to(x_position=-1) + + async def test_move_to_safe_rejects_out_of_range(self): + with self.assertRaises(ValueError): + await self.left_arm.move_to_safe(x_position=-1) + with self.assertRaises(ValueError): + await self.right_arm.move_to_safe(x_position=3001) diff --git a/pylabrobot/hamilton/liquid_handlers/star/wash_station.py b/pylabrobot/hamilton/liquid_handlers/star/wash_station.py new file mode 100644 index 00000000000..f4d3fe1806e --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/wash_station.py @@ -0,0 +1,135 @@ +"""STARWashStation: wash/pump station control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import enum +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARWashStation: + """Controls a wash / pump station on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for the dual-chamber pump station subsystem and delegates + I/O to the driver. + """ + + def __init__(self, driver: "STARDriver"): + self.driver = driver + + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self, backend_params=None): + pass + + async def _on_stop(self): + pass + + # -- commands -------------------------------------------------------------- + + class Type(enum.IntEnum): + """Pump station type enumeration.""" + + CORE_96_SINGLE = 0 + DC_SINGLE_REV_02 = 1 + RERERE_SINGLE = 2 + CORE_96_DUAL = 3 + DC_DUAL = 4 + RERERE_DUAL = 5 + + async def request_settings(self, station: int = 1) -> "Type": + """Query pump station type (C0:ET). + + Args: + station: pump station number (1..3). + + Returns: + Pump station type code: + 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) + """ + + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") + + resp = await self.driver.send_command(module="C0", command="ET", fmt="et#", ep=station) + return STARWashStation.Type(resp["et"]) + + async def initialize_valves(self, station: int = 1): + """Initialize pump station valves — dual chamber only (C0:EJ). + + Args: + station: pump station number (1..3). + """ + + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") + + return await self.driver.send_command(module="C0", command="EJ", ep=station) + + async def fill_chamber( + self, + station: int = 1, + drain_before_refill: bool = False, + wash_fluid: int = 1, + chamber: int = 2, + waste_chamber_suck_time_after_sensor_change: int = 0, + ): + """Fill selected dual chamber (C0:EH). + + The wash fluid / chamber combination is encoded as a connection index: + 0 = wash fluid 1 <-> chamber 2 + 1 = wash fluid 1 <-> chamber 1 + 2 = wash fluid 2 <-> chamber 1 + 3 = wash fluid 2 <-> chamber 2 + + Args: + station: pump station number (1..3). + drain_before_refill: drain chamber before refill. + wash_fluid: wash fluid selector (1 or 2). + chamber: chamber selector (1 or 2). + waste_chamber_suck_time_after_sensor_change: suck time in seconds after sensor + change (for error handling only). + """ + + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") + if not 1 <= wash_fluid <= 2: + raise ValueError("wash_fluid must be between 1 and 2") + if not 1 <= chamber <= 2: + raise ValueError("chamber must be between 1 and 2") + + # wash fluid <-> chamber connection + connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] + + return await self.driver.send_command( + module="C0", + command="EH", + ep=station, + ed=drain_before_refill, + ek=connection, + eu=f"{waste_chamber_suck_time_after_sensor_change:02}", + wait=False, + ) + + async def drain(self, station: int = 1): + """Drain dual chamber system (C0:EL). + + Args: + station: pump station number (1..3). + """ + + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") + + return await self.driver.send_command(module="C0", command="EL", ep=station) diff --git a/pylabrobot/hamilton/liquid_handlers/star/x_arm.py b/pylabrobot/hamilton/liquid_handlers/star/x_arm.py new file mode 100644 index 00000000000..0bf0aed699b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/x_arm.py @@ -0,0 +1,215 @@ +"""STARXArm: X-arm positioning control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal, Optional + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARXArm: + """Controls one X-arm (left or right) on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for X-arm positioning and delegates I/O to the driver. + + Args: + driver: The STARDriver instance to send commands through. + side: Which X-arm to control — ``"left"`` or ``"right"``. + """ + + def __init__(self, driver: "STARDriver", side: Literal["left", "right"]): + self.driver = driver + self._side = side + + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self, backend_params=None): + pass + + async def _on_stop(self): + pass + + # -- positioning (collision risk) ------------------------------------------ + + async def move_to(self, x_position: float = 0.0): + """Position X-arm (C0:JX for left, C0:JS for right). + + Collision risk! This moves the arm without raising components to Z-safety. + + Args: + x_position: X-position in mm. Must be between 0 and 3000. Default 0. + """ + + if not 0 <= x_position <= 3000.0: + raise ValueError("x_position must be between 0 and 3000 mm") + + if ( + self._side == "left" + and self.driver.left_side_panel_installed + and x_position < self.driver.PIP_X_MIN_WITH_LEFT_SIDE_PANEL + ): + raise ValueError( + f"PIP channel x={x_position}mm is below the minimum " + f"{self.driver.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm (left side panel is installed)" + ) + + cmd = "JX" if self._side == "left" else "JS" + return await self.driver.send_command( + module="C0", + command=cmd, + xs=f"{round(x_position * 10):05}", + ) + + # -- safe positioning (Z-safety) ------------------------------------------- + + async def move_to_safe(self, x_position: float = 0.0): + """Move X-arm to position with all attached components in Z-safety position + (C0:KX for left, C0:KR for right). + + Args: + x_position: X-position in mm. Must be between 0 and 3000. Default 0. + """ + + if not 0 <= x_position <= 3000.0: + raise ValueError("x_position must be between 0 and 3000 mm") + + if ( + self._side == "left" + and self.driver.left_side_panel_installed + and x_position < self.driver.PIP_X_MIN_WITH_LEFT_SIDE_PANEL + ): + raise ValueError( + f"PIP channel x={x_position}mm is below the minimum " + f"{self.driver.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm (left side panel is installed)" + ) + + cmd = "KX" if self._side == "left" else "KR" + return await self.driver.send_command( + module="C0", + command=cmd, + xs=round(x_position * 10), + ) + + # -- position query -------------------------------------------------------- + + async def request_position(self) -> float: + """Request current X-arm position (C0:RX for left, C0:QX for right). + + Returns: + X-position in mm (firmware value divided by 10). + """ + + cmd = "RX" if self._side == "left" else "QX" + resp = await self.driver.send_command(module="C0", command=cmd, fmt="rx#####") + return float(resp["rx"]) / 10 + + # -- collision type query -------------------------------------------------- + + async def last_collision_type(self) -> bool: + """Request last collision type after error 27 (C0:XX for left, C0:XR for right). + + Returns: + False if present positions collide (not reachable), + True if position is never reachable. + """ + + cmd = "XX" if self._side == "left" else "XR" + resp = await self.driver.send_command(module="C0", command=cmd, fmt="xq#") + return bool(resp["xq"] == 1) + + # -- cLLD X-probing ---------------------------------------------------------- + + async def clld_probe_x_position( + self, + channel_idx: int, + probing_direction: Literal["right", "left"], + end_pos_search: Optional[float] = None, + post_detection_dist: float = 2.0, + tip_bottom_diameter: float = 1.2, + read_timeout: float = 240.0, + ) -> float: + """Probe the x-position of a conductive material using cLLD via a lateral X scan. + + Starting from the current X position, the arm is moved laterally in the specified + direction using the XL command until cLLD triggers or the end position is reached. + After the scan, the arm is retracted by ``post_detection_dist``. + + The returned value is a geometric estimate of the material boundary, corrected by + half the tip bottom diameter assuming cylindrical tip contact. + + Preconditions: + - A channel must already be at a Z height safe for lateral X motion. + - The current X position must be consistent with ``probing_direction``. + + Args: + channel_idx: 0-indexed channel performing the probe. + probing_direction: ``"right"`` or ``"left"``. + end_pos_search: End position in mm. Defaults to max safe range for the direction. + post_detection_dist: Distance to retract after detection in mm. + tip_bottom_diameter: Effective diameter of the tip bottom in mm. + read_timeout: Timeout in seconds for the XL command. + + Returns: + Estimated x-position of the detected material boundary in mm. + """ + if probing_direction not in ("right", "left"): + raise ValueError(f"probing_direction must be 'right' or 'left', got {probing_direction!r}") + if post_detection_dist < 0.0: + raise ValueError(f"post_detection_dist must be non-negative, got {post_detection_dist}") + + current_x = await self.request_position() + + assert self.driver.extended_conf is not None + num_rails = self.driver.extended_conf.instrument_size_slots + track_width = 22.5 # mm + reachable_dist_to_last_rail = 125.0 + max_safe_upper = num_rails * track_width + reachable_dist_to_last_rail + max_safe_lower = 95.0 # mm + + if end_pos_search is None: + end_pos_search = max_safe_upper if probing_direction == "right" else max_safe_lower + elif not (max_safe_lower <= end_pos_search <= max_safe_upper): + raise ValueError( + f"end_pos_search must be between {max_safe_lower} and {max_safe_upper} mm, " + f"got {end_pos_search}" + ) + + if probing_direction == "right" and current_x >= end_pos_search: + raise ValueError( + f"Current position ({current_x} mm) must be < end position ({end_pos_search} mm) " + "when probing right." + ) + if probing_direction == "left" and current_x <= end_pos_search: + raise ValueError( + f"Current position ({current_x} mm) must be > end position ({end_pos_search} mm) " + "when probing left." + ) + + # C0:XL — move arm in X until cLLD triggers on the specified channel + # Note: pn (channel index) is NOT sent here. The firmware uses whichever + # channel is at the correct Z height for cLLD sensing. The channel_idx is + # used only to query the post-probe position. + await self.driver.send_command( + module="C0", + command="XL", + xs=f"{int(round(end_pos_search * 10)):05}", + read_timeout=int(read_timeout), + ) + + sensor_triggered_x = await self.request_position() + + if probing_direction == "left": + final_x = sensor_triggered_x + post_detection_dist + material_x = sensor_triggered_x - tip_bottom_diameter / 2 + else: + final_x = sensor_triggered_x - post_detection_dist + material_x = sensor_triggered_x + tip_bottom_diameter / 2 + + await self.move_to(final_x) + + return round(material_x, 1) diff --git a/pylabrobot/hamilton/liquid_handlers/tcp_base.py b/pylabrobot/hamilton/liquid_handlers/tcp_base.py new file mode 100644 index 00000000000..abf0b7f9053 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/tcp_base.py @@ -0,0 +1,585 @@ +"""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.device import Driver +from pylabrobot.io.binary import Reader +from pylabrobot.io.socket import Socket +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import ( + CommandResponse, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) + +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(Driver): + """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/only_fans/__init__.py b/pylabrobot/hamilton/only_fans/__init__.py new file mode 100644 index 00000000000..d3b71629c3d --- /dev/null +++ b/pylabrobot/hamilton/only_fans/__init__.py @@ -0,0 +1,6 @@ +from .backend import ( + HamiltonHepaFanChatterboxBackend, + HamiltonHepaFanDriver, + HamiltonHepaFanFanBackend, +) +from .hepa_fan import HamiltonHepaFan diff --git a/pylabrobot/hamilton/only_fans/backend.py b/pylabrobot/hamilton/only_fans/backend.py new file mode 100644 index 00000000000..362049c0239 --- /dev/null +++ b/pylabrobot/hamilton/only_fans/backend.py @@ -0,0 +1,181 @@ +import asyncio +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.fan_control import FanBackend +from pylabrobot.device import Driver +from pylabrobot.io.ftdi import FTDI + +logger = logging.getLogger(__name__) + +_SPEED_TABLE = [ + "55c10111007b", + "55c101110279", + "55c10111057e", + "55c10111077c", + "55c101110a71", + "55c101110c77", + "55c101110f74", + "55c10111116a", + "55c10111146f", + "55c10111166d", + "55c101111962", + "55c101111c67", + "55c101111e65", + "55c10111215a", + "55c101112358", + "55c10111265d", + "55c101112853", + "55c101112b50", + "55c101112d56", + "55c10111304b", + "55c101113249", + "55c10111354e", + "55c101113843", + "55c101113a41", + "55c101113d46", + "55c101113f44", + "55c101114239", + "55c10111443f", + "55c10111473c", + "55c101114932", + "55c101114c37", + "55c101114f34", + "55c10111512a", + "55c10111542f", + "55c10111562d", + "55c101115922", + "55c101115b20", + "55c101115e25", + "55c10111601b", + "55c101116318", + "55c10111651e", + "55c101116813", + "55c101116b10", + "55c101116d16", + "55c10111700b", + "55c101117209", + "55c10111750e", + "55c10111770c", + "55c101117a01", + "55c101117c07", + "55c101117f04", + "55c1011182f9", + "55c1011184ff", + "55c1011187fc", + "55c1011189f2", + "55c101118cf7", + "55c101118ef5", + "55c1011191ea", + "55c1011193e8", + "55c1011196ed", + "55c1011198e3", + "55c101119be0", + "55c101119ee5", + "55c10111a0db", + "55c10111a3d8", + "55c10111a5de", + "55c10111a8d3", + "55c10111aad1", + "55c10111add6", + "55c10111afd4", + "55c10111b2c9", + "55c10111b5ce", + "55c10111b7cc", + "55c10111bac1", + "55c10111bcc7", + "55c10111bfc4", + "55c10111c1ba", + "55c10111c4bf", + "55c10111c6bd", + "55c10111c9b2", + "55c10111cbb0", + "55c10111ceb5", + "55c10111d1aa", + "55c10111d3a8", + "55c10111d6ad", + "55c10111d8a3", + "55c10111dba0", + "55c10111dda6", + "55c10111e09b", + "55c10111e299", + "55c10111e59e", + "55c10111e893", + "55c10111ea91", + "55c10111ed96", + "55c10111ef94", + "55c10111f289", + "55c10111f48f", + "55c10111f78c", + "55c10111f982", + "55c10111fc87", + "55c10111fe85", +] + + +class HamiltonHepaFanDriver(Driver): + """FTDI driver for the Hamilton HEPA fan.""" + + def __init__(self, device_id: Optional[str] = None): + self.io = FTDI( + human_readable_device_name="Hamilton HEPA Fan", + device_id=device_id, + vid=0x0856, + pid=0xAC11, + ) + + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.io.setup() + await self.io.set_baudrate(9600) + await self.io.set_line_property(8, 0, 0) # 8N1 + await self.io.set_latency_timer(16) + await self.io.set_flowctrl(512) + await self.io.set_dtr(True) + await self.io.set_rts(True) + + await self.send(b"\x55\xc1\x01\x02\x23\x4b") + await self.send(b"\x55\xc1\x01\x08\x08\x6a") + await self.send(b"\x55\xc1\x01\x09\x6a\x09") + await self.send(b"\x55\xc1\x01\x0a\x2f\x4f") + await self.send(b"\x15\x61\x01\x8a") + logger.info("[HEPA fan %s] initialized", self.io.device_id or "default") + + async def stop(self): + await self.io.stop() + logger.info("[HEPA fan %s] stopped", self.io.device_id or "default") + + async def send(self, command: bytes): + await self.io.write(command) + await asyncio.sleep(0.1) + await self.io.read(64) + + +class HamiltonHepaFanFanBackend(FanBackend): + """Translates FanBackend calls into FTDI commands.""" + + def __init__(self, driver: HamiltonHepaFanDriver): + self.driver = driver + + async def turn_on(self, intensity: int) -> None: + if int(intensity) != intensity or not 0 <= intensity <= 100: + raise ValueError("Intensity must be an integer between 0 and 100") + logger.info( + "[HEPA fan %s] turning on at intensity %d%%", self.driver.io.device_id or "default", intensity + ) + await self.driver.send(b"\x35\x41\x01\xff\x75") + await self.driver.send(bytes.fromhex(_SPEED_TABLE[intensity])) + + async def turn_off(self) -> None: + logger.info("[HEPA fan %s] turning off", self.driver.io.device_id or "default") + await self.driver.send(b"\x55\xc1\x01\x11\x00\x7b") + + +class HamiltonHepaFanChatterboxBackend(FanBackend): + """Chatterbox backend for device-free testing.""" + + async def turn_on(self, intensity: int) -> None: + pass + + async def turn_off(self) -> None: + pass diff --git a/pylabrobot/hamilton/only_fans/hepa_fan.py b/pylabrobot/hamilton/only_fans/hepa_fan.py new file mode 100644 index 00000000000..17ea48330b3 --- /dev/null +++ b/pylabrobot/hamilton/only_fans/hepa_fan.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pylabrobot.capabilities.fan_control import Fan +from pylabrobot.device import Device + +from .backend import HamiltonHepaFanDriver, HamiltonHepaFanFanBackend + + +class HamiltonHepaFan(Device): + """Hamilton HEPA fan attachment.""" + + def __init__(self, name: str, device_id: Optional[str] = None): + driver = HamiltonHepaFanDriver(device_id=device_id) + super().__init__(driver=driver) + self.driver: HamiltonHepaFanDriver = driver + self.fan = Fan(backend=HamiltonHepaFanFanBackend(driver)) + self._capabilities = [self.fan] diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py new file mode 100644 index 00000000000..35658d754b5 --- /dev/null +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -0,0 +1,24 @@ +"""Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.).""" + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action, + HamiltonDataType, + HamiltonProtocol, + HarpTransportableProtocol, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py similarity index 94% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py rename to pylabrobot/hamilton/tcp/commands.py index 633a6b15c01..1867d2149ca 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -1,7 +1,7 @@ """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. +architecture: Wire -> HoiParams -> Packets -> Messages -> Commands. """ from __future__ import annotations @@ -9,13 +9,13 @@ import inspect from typing import Optional -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.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.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol class HamiltonCommand: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py similarity index 98% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py rename to pylabrobot/hamilton/tcp/introspection.py index 247de40fde1..fd6fedb04ab 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/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.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import ( HamiltonDataType, HamiltonProtocol, ) @@ -235,7 +238,7 @@ def get_signature_string(self) -> str: # Format return based on category if any(cat == "ReturnElement" for cat in return_categories): - # Multiple return values → struct format + # 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 = [ diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py similarity index 97% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py rename to pylabrobot/hamilton/tcp/messages.py index df32f5289ab..75fe0fb974a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/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.hamilton.tcp.packets import ( Address, HarpPacket, HoiPacket, IpPacket, RegistrationPacket, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.hamilton.tcp.protocol import ( HamiltonDataType, HarpTransportableProtocol, RegistrationOptionType, @@ -412,20 +412,14 @@ def _parse_value(self, type_id: int, data: bytes) -> Any: count = len(data) // element_size return [array_element_parsers[data_type]() for _ in range(count)] elif data_type == HamiltonDataType.STRING_ARRAY: - # String arrays: null-terminated strings concatenated, no count prefix - # Parse by splitting on null bytes + # String arrays: [count:4][str0\0][str1\0]... + count = Reader(data[:4]).u32() strings = [] - current_string = bytearray() - for byte in data: - if byte == 0: - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - current_string = bytearray() - else: - current_string.append(byte) - # Handle case where last string doesn't end with null (shouldn't happen, but be safe) - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) + 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 diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py b/pylabrobot/hamilton/tcp/packets.py similarity index 96% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py rename to pylabrobot/hamilton/tcp/packets.py index fb301cfbef6..42d308f1c48 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py +++ b/pylabrobot/hamilton/tcp/packets.py @@ -12,11 +12,14 @@ 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 @@ -37,14 +40,14 @@ def encode_version_byte(major: int, minor: int) -> int: return version_byte -def decode_version_byte(version_bite: int) -> tuple[int, int]: +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_bite & 0xF - major = (version_bite >> 4) & 0xF + minor = version_byte & 0xF + major = (version_byte >> 4) & 0xF return (major, minor) @@ -113,8 +116,13 @@ def unpack(cls, data: bytes) -> "IpPacket": # Validate version if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: - # Warning but not fatal - pass + 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"" diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/hamilton/tcp/protocol.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py rename to pylabrobot/hamilton/tcp/protocol.py diff --git a/pylabrobot/hamilton/tcp/tests/__init__.py b/pylabrobot/hamilton/tcp/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/tilt_module/__init__.py b/pylabrobot/hamilton/tilt_module/__init__.py new file mode 100644 index 00000000000..650fa1298e4 --- /dev/null +++ b/pylabrobot/hamilton/tilt_module/__init__.py @@ -0,0 +1,6 @@ +from .backend import ( + HamiltonTiltModuleChatterboxTilterBackend, + HamiltonTiltModuleDriver, + HamiltonTiltModuleTilterBackend, +) +from .tilt_module import HamiltonTiltModule diff --git a/pylabrobot/hamilton/tilt_module/backend.py b/pylabrobot/hamilton/tilt_module/backend.py new file mode 100644 index 00000000000..e4ed7b700c0 --- /dev/null +++ b/pylabrobot/hamilton/tilt_module/backend.py @@ -0,0 +1,324 @@ +import logging +import re +from typing import Optional + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.tilting.backend import TilterBackend, TiltModuleError +from pylabrobot.device import Driver +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class HamiltonTiltModuleDriver(Driver): + """Serial driver for the Hamilton tilt module. + + Owns the hardware connection. Knows how to send bytes on the wire. + """ + + def __init__( + self, + com_port: str, + write_timeout: float = 10, + timeout: float = 10, + ): + if not HAS_SERIAL: + raise RuntimeError( + f"pyserial is required for the Hamilton tilt module backend. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + + super().__init__() + self.com_port = com_port + self.io = Serial( + port=self.com_port, + baudrate=1200, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + write_timeout=write_timeout, + timeout=timeout, + human_readable_device_name="Hamilton Tilt Module", + ) + + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.io.setup() + logger.info("[Tilt %s] connected", self.com_port) + + async def stop(self): + await self.io.stop() + logger.info("[Tilt %s] disconnected", self.com_port) + + async def send_command(self, command: str, parameter: Optional[str] = None) -> str: + """Send a command to the tilt module.""" + + if parameter is None: + parameter = "" + + await self.io.write(f"99{command}{parameter}\r\n".encode("utf-8")) + resp = "" + while not resp.startswith("T1" + command): + resp = (await self.io.read(128)).decode("utf-8") + + # Check for error. + error_matches = re.search("er[0-9]{2}", resp) + if error_matches is not None: + err_code = int(error_matches.group(0)[2:]) + if 1 <= err_code <= 7: + error_msg = { + 1: "Init Position not found", + 2: "**Step** loss", + 3: "Not initialized", + 5: "Stepper Motor end stage defective", + 6: "Parameter out **of** Range", + 7: "Undefined Command", + }[err_code] + logger.error("[Tilt %s] error %d: %s", self.com_port, err_code, error_msg) + raise TiltModuleError(error_msg) + if err_code != 0: + logger.error("[Tilt %s] unexpected error code: %d", self.com_port, err_code) + raise RuntimeError(f"Unexpected error code: {err_code}") + + return resp + + +class HamiltonTiltModuleTilterBackend(TilterBackend): + """Translates TilterBackend interface into Hamilton tilt module driver commands. + + Protocol encoding lives here -- the backend knows that set_angle means + calling tilt_go_to_position via the driver's send_command. + """ + + def __init__(self, driver: HamiltonTiltModuleDriver): + self.driver = driver + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + await self.tilt_initial_offset(0) + await self.tilt_initialize() + + async def set_angle(self, angle: float): + """Set the tilt module to rotate by a given angle.""" + + if not (0 <= angle <= 10): + raise ValueError("Angle must be between 0 and 10 degrees.") + + logger.info("[Tilt %s] set angle: angle=%.1f deg", self.driver.com_port, angle) + await self.tilt_go_to_position(round(angle)) + + async def tilt_initialize(self): + """Initialize a daisy chained tilt module.""" + + return await self.driver.send_command("SI") + + async def tilt_move_to_absolute_step_position(self, position: float): + """Move the tilt module to an absolute position. + + Args: + position: absolute position (-10...120) + """ + + if not (-10 <= position <= 120): + raise ValueError("Position must be between -10 and 120.") + + return await self.driver.send_command( + command="SA", + parameter=str(position), + ) + + async def tilt_move_to_relative_step_position(self, steps: float): + """Move the tilt module to a relative position. + + .. warning:: This method has the potential to decalibrate the tilt module. + + Args: + steps: the number of steps (+-10000) + """ + + if not (-10000 <= steps <= 10000): + raise ValueError("Steps must be between -10000 and 10000.") + + return await self.driver.send_command(command="SR", parameter=str(steps)) + + async def tilt_go_to_position(self, position: int): + """Go to position (0...10). + + Args: + position: 0 = horizontal, 10 = degrees + """ + + if not (0 <= position <= 10): + raise ValueError("Position must be between 0 and 10.") + + return await self.driver.send_command(command="GP", parameter=str(position)) + + async def tilt_set_speed(self, speed: int): + """Set the speed on the tilt module. + + Args: + speed: 1 is slow, 9 = fast. Default speed is 1. + """ + + if not (1 <= speed <= 9): + raise ValueError("Speed must be between 1 and 9.") + + return await self.driver.send_command(command="SV", parameter=str(speed)) + + async def tilt_power_off(self): + """Power off the tilt module.""" + + return await self.driver.send_command(command="PO") + + async def tilt_request_error(self) -> Optional[str]: + """Request the error of the tilt module. + + Returns: the error, if it exists, else `None` + """ + + # send_command will automatically raise an error, if one exists + return await self.driver.send_command("RE") + + async def tilt_request_sensor(self) -> Optional[str]: + """Request sensor status. + + 0 = LS 1 Input, 1 = LS 2 Input, 2 = LS 3 Input, + 3 = PNP Input 1, 4 = PNP Input 2, 5 = PNP Input 3, + 6 = NPN Input 1, 7 = NPN Input 2 + """ + + resp = await self.driver.send_command(command="RX") + resp = resp[:-2].split(" ")[1] + code = int(resp) + + if code == 0: + return None + if 1 <= code <= 7: + return { + 0: "LS 1 Input", + 1: "LS 2 Input", + 2: "LS 3 Input", + 3: "PNP Input 1", + 4: "PNP Input 2", + 5: "PNP Input 3", + 6: "NPN Input 1", + 7: "NPN Input 2", + }[code] + raise RuntimeError(f"Unexpected error code: {code}") + + async def tilt_request_offset_between_light_barrier_and_init_position(self) -> int: + """Request Offset between Light Barrier and Init Position""" + + resp = await self.driver.send_command(command="RO") + resp = resp[:-2].split(" ")[1] + return int(resp) + + async def tilt_port_set_open_collector(self, open_collector: int): + """Port set open collector. + + Args: + open_collector: 1...8 + """ + + if not (1 <= open_collector <= 8): + raise ValueError("open_collector must be between 1 and 8") + + return await self.driver.send_command(command="PS", parameter=str(open_collector)) + + async def tilt_port_clear_open_collector(self, open_collector: int): + """Tilt port clear open collector. + + Args: + open_collector: 1...8 + """ + + if not (1 <= open_collector <= 8): + raise ValueError("open_collector must be between 1 and 8") + + return await self.driver.send_command(command="PC", parameter=str(open_collector)) + + async def tilt_set_temperature(self, temperature: float): + """Set the temperature (10-50 degrees C). + + Args: + temperature: temperature in Celsius, between 10 and 50 + """ + + if not (10 <= temperature <= 50): + raise ValueError("Temperature must be between 10 and 50.") + + logger.info("[Tilt %s] set temperature: target=%.1f C", self.driver.com_port, temperature) + return await self.driver.send_command(command="ST", parameter=str(int(temperature * 10))) + + async def tilt_switch_off_temperature_controller(self): + """Switch off the temperature controller on the tilt module.""" + + return await self.driver.send_command(command="TO") + + async def tilt_set_drain_time(self, drain_time: float): + """Set the drain time on the tilt module. + + Args: + drain_time: drain time in seconds, between 5 and 250 + """ + + if not (5 <= drain_time <= 250): + raise ValueError("Drain time must be between 5 and 250.") + + return await self.driver.send_command(command="DT", parameter=str(int(drain_time * 10))) + + async def tilt_set_waste_pump_on(self): + """Turn the waste pump on.""" + + return await self.driver.send_command(command="WP") + + async def tilt_set_waste_pump_off(self): + """Turn the waste pump off.""" + + return await self.driver.send_command(command="WO") + + async def tilt_set_name(self, name: str): + """Set the tilt module name. + + Args: + name: the desired name, must be 2 characters long + """ + + if len(name) != 2: + raise ValueError("name must be 2 characters long") + + return await self.driver.send_command(command="MN", parameter=name) + + async def tilt_switch_encoder(self, on: bool): + """Switch the encoder on or off. + + Args: + on: if True, the encoder will be turned on, else off. + """ + + return await self.driver.send_command(command="EN", parameter=str(int(on))) + + async def tilt_initial_offset(self, offset: int): + """Set the initial offset on the tilt module. + + Args: + offset: the initial offset steps, between -100 and 100 + """ + + if not (-100 <= offset <= 100): + raise ValueError("Offset must be between -100 and 100.") + + return await self.driver.send_command(command="SO", parameter=str(offset)) + + +class HamiltonTiltModuleChatterboxTilterBackend(TilterBackend): + """No-op backend for testing without hardware.""" + + async def set_angle(self, angle: float): + pass diff --git a/pylabrobot/hamilton/tilt_module/tilt_module.py b/pylabrobot/hamilton/tilt_module/tilt_module.py new file mode 100644 index 00000000000..ff350c694da --- /dev/null +++ b/pylabrobot/hamilton/tilt_module/tilt_module.py @@ -0,0 +1,152 @@ +import math +from typing import List, Optional + +from pylabrobot.capabilities.tilting import Tilter +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Plate +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.well import CrossSectionType, Well + +from .backend import HamiltonTiltModuleDriver, HamiltonTiltModuleTilterBackend + + +class HamiltonTiltModule(ResourceHolder, Device): + """A Hamilton tilt module.""" + + def __init__( + self, + name: str, + com_port: str, + child_location: Coordinate = Coordinate(1.0, 3.0, 83.55), + pedestal_size_z: float = 3.47, + write_timeout: float = 3, + timeout: float = 3, + ): + driver = HamiltonTiltModuleDriver( + com_port=com_port, + write_timeout=write_timeout, + timeout=timeout, + ) + ResourceHolder.__init__( + self, + name=name, + size_x=132, + size_y=92.57, + size_z=85.81, + child_location=child_location, + category="tilter", + model="HamiltonTiltModule", + ) + Device.__init__(self, driver=driver) + self.driver: HamiltonTiltModuleDriver = driver + self.pedestal_size_z = pedestal_size_z + self._hinge_coordinate = Coordinate(6.18, 0, 72.85) + + self.tilter = Tilter(backend=HamiltonTiltModuleTilterBackend(driver=driver)) + self._capabilities = [self.tilter] + + @property + def hinge_coordinate(self) -> Coordinate: + return self._hinge_coordinate + + def rotate_coordinate_around_hinge( + self, absolute_coordinate: Coordinate, angle: float + ) -> Coordinate: + """Rotate an absolute coordinate around the hinge by a given angle. + + Args: + absolute_coordinate: The coordinate to rotate. + angle: The angle to rotate by, in degrees. Negative is clockwise. + """ + theta = math.radians(angle) + origin = self.get_absolute_location("l", "f", "b") + + rotation_arm_x = absolute_coordinate.x - (self._hinge_coordinate.x + origin.x) + rotation_arm_z = absolute_coordinate.z - (self._hinge_coordinate.z + origin.z) + + x_prime = rotation_arm_x * math.cos(theta) - rotation_arm_z * math.sin(theta) + z_prime = rotation_arm_x * math.sin(theta) + rotation_arm_z * math.cos(theta) + + new_x = x_prime + (self._hinge_coordinate.x + origin.x) + new_z = z_prime + (self._hinge_coordinate.z + origin.z) + + return Coordinate(new_x, absolute_coordinate.y, new_z) + + def get_plate_drain_offsets( + self, plate: Plate, absolute_angle: Optional[float] = None + ) -> List[Coordinate]: + """Get drain edge offsets for all wells in the plate at the given tilt angle. + + Args: + plate: The plate to calculate the offsets for. + absolute_angle: The absolute angle. If None, uses the current tilt angle. + """ + if absolute_angle is None: + absolute_angle = self.tilter.absolute_angle + angle = absolute_angle if self._hinge_coordinate.x < self._size_x / 2 else -absolute_angle + hinge_side = "l" if self._hinge_coordinate.x < self._size_x / 2 else "r" + + well_drain_offsets = [] + for well in plate.children: + level_coord = well.get_absolute_location(hinge_side, "c", "b") + rotated_coord = self.rotate_coordinate_around_hinge(level_coord, angle) + offset = rotated_coord - well.get_absolute_location("c", "c", "b") + well_drain_offsets.append(offset) + + return well_drain_offsets + + def get_well_drain_offsets( + self, + wells: List[Well], + n_tips: int = 1, + absolute_angle: Optional[float] = None, + ) -> List[Coordinate]: + """Get drain edge offsets for the given wells at the given tilt angle. + + Args: + wells: The wells to calculate the offsets for. + n_tips: The number of tips per well. Defaults to 1. + absolute_angle: The absolute angle. If None, uses the current tilt angle. + """ + if absolute_angle is None: + absolute_angle = self.tilter.absolute_angle + angle = absolute_angle * (-1 if self._hinge_coordinate.x >= self._size_x / 2 else 1) + + hinge_on_left = self._hinge_coordinate.x < self._size_x / 2 + min_tip_distance = 9 # mm + + well_drain_offsets = [] + for well in wells: + assert well.cross_section_type == CrossSectionType.CIRCLE, ( + "Wells must have circular cross-section" + ) + + diameter = well.get_absolute_size_x() + radius = diameter / 2 + + if n_tips > 1: + assert (n_tips - 1) * min_tip_distance <= diameter, ( + f"Cannot fit {n_tips} tips in a well with diameter {diameter} mm" + ) + y_offsets = [ + ((n_tips - 1) / 2 - tip_index) * min_tip_distance for tip_index in range(n_tips) + ] + x_offset = math.sqrt(radius**2 - max(y_offsets) ** 2) + x_offset = -x_offset if hinge_on_left else x_offset + tip_coords = [Coordinate(x_offset, y, 0) for y in y_offsets] + else: + x_offset = -radius if hinge_on_left else radius + tip_coords = [Coordinate(x_offset, 0, 0)] + + offsets = [] + for tip_coord in tip_coords: + rotated_tip = self.rotate_coordinate_around_hinge( + well.get_absolute_location("c", "c", "b") + tip_coord, + angle, + ) + offset = rotated_tip - well.get_absolute_location("c", "c", "b") + offsets.append(offset) + + well_drain_offsets.append(offsets) + + return [offset for well_offsets in well_drain_offsets for offset in well_offsets] diff --git a/pylabrobot/hamilton/usb/__init__.py b/pylabrobot/hamilton/usb/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/usb/driver.py b/pylabrobot/hamilton/usb/driver.py new file mode 100644 index 00000000000..d50ed595f89 --- /dev/null +++ b/pylabrobot/hamilton/usb/driver.py @@ -0,0 +1,420 @@ +import asyncio +import datetime +import logging +import threading +import time +import warnings +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from typing import ( + Any, + List, + Optional, + Tuple, + TypeVar, +) + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +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(Driver, 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. + """ + + super().__init__() + 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] = [] + + def __setattr__(self, name: str, value: Any) -> None: + if name == "allow_firmware_planning": + warnings.warn( + "allow_firmware_planning is deprecated and will be removed in a future version. " + "The behavior is now always enabled.", + DeprecationWarning, + stacklevel=2, + ) + return + super().__setattr__(name, value) + + async def setup(self, backend_params: Optional[BackendParams] = None): + await super().setup(backend_params=backend_params) # type: ignore[safe-super] + 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() + + def serialize(self) -> dict: + usb_serialized = self.io.serialize() + del usb_serialized["id_vendor"] + del usb_serialized["id_product"] + del usb_serialized["human_readable_device_name"] + return {**super().serialize(), **usb_serialized} + + @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/bioshake_backend.py b/pylabrobot/heating_shaking/bioshake_backend.py deleted file mode 100644 index 2816363b5a2..00000000000 --- a/pylabrobot/heating_shaking/bioshake_backend.py +++ /dev/null @@ -1,268 +0,0 @@ -import asyncio -import warnings - -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.io.serial import Serial -from pylabrobot.machines.backend import MachineBackend - -try: - import serial - - HAS_SERIAL = True -except ImportError as e: - HAS_SERIAL = False - _SERIAL_IMPORT_ERROR = e - - -class BioShake(HeaterShakerBackend): - def __init__(self, port: str, timeout: int = 60): - if not HAS_SERIAL: - raise RuntimeError( - f"pyserial is required for the BioShake module backend. Import error: {_SERIAL_IMPORT_ERROR}" - ) - - self.setup_finished = False - self.port = port - self.timeout = timeout - self.io = Serial( - port=self.port, - baudrate=9600, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - write_timeout=10, - timeout=self.timeout, - human_readable_device_name="BioShake", - ) - - async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): - try: - # Flush serial buffers for a clean start - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - - # Send the command - await self.io.write((cmd + "\r").encode("ascii")) - await asyncio.sleep(delay) - - # Read and decode the response with a timeout - 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() - - # Parsing the response from the BioShake - - # No response at all - if not decoded: - raise RuntimeError(f"No response for '{cmd}'") - - # Device-specific errors - if decoded.startswith("e"): - raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") - - if decoded.startswith("u ->"): - raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") - - # Standard OK - if decoded.lower().startswith("ok"): - return None - - # All other valid responses (e.g. temperature and remaining time) - 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): - await MachineBackend.setup(self) - await self.io.setup() - if not skip_home: - # Reset first before homing it to ensure the device is ready for run - await self.reset() - # Additional seconds until next command can be send after reset - await asyncio.sleep(4) - # Now home the device - await self.home() - - async def stop(self): - await MachineBackend.stop(self) - await self.io.stop() - - async def reset(self): - # Reset the BioShake if stuck in "e" state - # Flush serial buffers for a clean start - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - - # Send the command - await self.io.write(("resetDevice\r").encode("ascii")) - - start = asyncio.get_event_loop().time() - max_seconds = 30 # How long a reset typically last - - while True: - # Break the loop if process takes longer than 30 seconds - if asyncio.get_event_loop().time() - start > max_seconds: - raise TimeoutError("Reset did not complete in time") - - try: - # Wait for each line with a timeout - 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: - # Stop when the final message arrives - if "Initialization complete" in decoded: - break - - except asyncio.TimeoutError: - # Keep polling if nothing arrives within timeout - continue - - async def home(self): - # Initialize the BioShake into home position - await self._send_command(cmd="shakeGoHome", delay=5) - - async def start_shaking(self, speed: float, acceleration: int = 0): - # Check if speed is an integer - 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__}" - ) - - # Get the min and max speed of the device to assert speed - 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))) - - assert min_speed <= speed <= max_speed, ( - f"Speed {speed} RPM is out of range. Allowed range is {min_speed}{max_speed} RPM" - ) - - # Set the speed of the shaker - set_speed_cmd = f"setShakeTargetSpeed{speed}" - await self._send_command(cmd=set_speed_cmd) - - # Check if accel is an integer - if isinstance(acceleration, float): - if not acceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded - 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__}" - ) - - # Get the min and max acceleration of the device to check bounds - 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))) - - assert min_accel <= acceleration <= max_accel, ( - f"Acceleration {acceleration} seconds is out of range. Allowed range is {min_accel}-{max_accel} seconds" - ) - - # Set the acceleration of the shaker - set_accel_cmd = f"setShakeAcceleration{acceleration}" - await self._send_command(cmd=set_accel_cmd, delay=0.2) - - # Send the command to start shaking, either with or without duration - - await self._send_command(cmd="shakeOn", delay=0.2) - - async def shake(self, speed: float, acceleration: int = 0): - warnings.warn( - "BioShake.shake() is deprecated and will be removed in a future release. " - "Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed, acceleration=acceleration) - - async def stop_shaking(self, deceleration: int = 0): - # Check if decel is an integer - if isinstance(deceleration, float): - if not deceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded - 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, not {type(deceleration).__name__}" - ) - - # Get the min and max decel of the device to asset decel - 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))) - - assert min_decel <= deceleration <= max_decel, ( - f"Deceleration {deceleration} seconds is out of range. Allowed range is {min_decel}-{max_decel} seconds" - ) - - # Set the deceleration of the shaker - set_decel_cmd = f"setShakeAcceleration{deceleration}" - await self._send_command(cmd=set_decel_cmd, delay=0.2) - - # stop shaking - await self._send_command(cmd="shakeOff", delay=0.2) - - # The BioShake 3000 ELM firmware needs the motor to fully decelerate - # before the edge-locking mechanism (ELM) can operate. Without this - # delay, subsequent setElmUnlockPos commands return 'e' (error). - sleep_time_after_stop = 3 - await asyncio.sleep(sleep_time_after_stop) - - @property - def supports_locking(self) -> bool: - return True - - async def lock_plate(self): - await self._send_command(cmd="setElmLockPos", delay=0.3) - - async def unlock_plate(self): - await self._send_command(cmd="setElmUnlockPos", delay=0.3) - - @property - def supports_active_cooling(self) -> bool: - return True - - async def set_temperature(self, temperature: float): - # Get the min and max set points of the device to assert temperature - 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))) - - assert min_temp <= temperature <= max_temp, ( - f"Temperature {temperature} C is out of range. Allowed range is {min_temp}–{max_temp} C." - ) - - temperature = temperature * 10 - - # Check if temperature is an integer - if isinstance(temperature, float): - if not temperature.is_integer(): - raise ValueError(f"Temperature must be a whole number, not {temperature} (1/10 C)") - temperature = int(temperature) - if not isinstance(temperature, int): - raise TypeError( - f"Temperature must be an integer or a whole number float, not {type(temperature).__name__} (1/10 C)" - ) - - set_temp_cmd = f"setTempTarget{temperature}" - await self._send_command(cmd=set_temp_cmd, delay=0.2) - - # Start temperature control - await self._send_command(cmd="tempOn", delay=0.2) - - async def get_current_temperature(self) -> float: - response = await self._send_command(cmd="getTempActual", delay=0.2) - return float(response) - - async def deactivate(self): - # Stop temperature control - await self._send_command(cmd="tempOff", delay=0.2) 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/heating_shaking/hamilton_backend.py b/pylabrobot/heating_shaking/hamilton_backend.py deleted file mode 100644 index f302502115a..00000000000 --- a/pylabrobot/heating_shaking/hamilton_backend.py +++ /dev/null @@ -1,221 +0,0 @@ -import abc -import time -import warnings -from enum import Enum -from typing import Dict, Literal, Optional - -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.io.usb import USB - - -class PlateLockPosition(Enum): - LOCKED = 1 - UNLOCKED = 0 - - -class HamiltonHeaterShakerInterface(abc.ABC): - """Either a control box or a STAR: the api is the same""" - - @abc.abstractmethod - async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: - pass - - -class HamiltonHeaterShakerBox(HamiltonHeaterShakerInterface): - def __init__( - self, - id_vendor: int = 0x8AF, - id_product: int = 0x8002, - device_address: Optional[int] = None, - serial_number: Optional[str] = None, - ): - self.io = USB( - human_readable_device_name="Hamilton Heater Shaker Box", - id_vendor=id_vendor, - id_product=id_product, - device_address=device_address, - serial_number=serial_number, - ) - self._id = 0 - - def _generate_id(self) -> int: - """continuously generate unique ids 0 <= x < 10000.""" - self._id += 1 - return self._id % 10000 - - async def setup(self): - """ - If io.setup() fails, ensure that libusb drivers were installed for the HHS as per docs. - """ - await self.io.setup() - - async def stop(self): - await self.io.stop() - - async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: - args = "".join([f"{key}{value}" for key, value in kwargs.items()]) - id_ = str(self._generate_id()).zfill(4) - await self.io.write(f"T{index}{command}id{id_}{args}".encode()) - return (await self.io.read()).decode("utf-8") - - -class HamiltonHeaterShakerBackend(HeaterShakerBackend): - """Backend for Hamilton Heater Shaker devices connected through an Heater Shaker Box""" - - @property - def supports_active_cooling(self) -> bool: - return False - - def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: - """ - Multiple Hamilton Heater Shakers can be connected to the same Heat Shaker Box. Each has A - unique 'shaker index' - """ - assert index >= 0, "Shaker index must be non-negative" - self.index = index - - super().__init__() - self.interface = interface - - async def setup(self): - """ - If io.setup() fails, ensure that libusb drivers were installed for the HHS as per docs. - """ - await self._initialize_lock() - await self._initialize_shaker_drive() - - async def stop(self): - pass - - def serialize(self) -> dict: - warnings.warn("The interface is not serialized.") - - heater_shaker_serialized = HeaterShakerBackend.serialize(self) - return { - **heater_shaker_serialized, - "index": self.index, - "interface": None, # TODO: implement serialization - } - - async def start_shaking( - self, - speed: float = 800, - direction: Literal[0, 1] = 0, - acceleration: int = 1_000, - timeout: Optional[float] = 30, - ): - """ - if the plate is not locked, it will be locked. - - speed: steps per second - direction: 0 for positive, 1 for negative - acceleration: increments per second - """ - - await self.lock_plate() - - int_speed = int(speed) - assert 20 <= int_speed <= 2_000, "Speed must be between 20 and 2_000" - assert direction in [0, 1], "Direction must be 0 or 1" - assert 500 <= acceleration <= 10_000, "Acceleration must be between 500 and 10_000" - - now = time.time() - while True: - await self._start_shaking(direction=direction, speed=int_speed, acceleration=acceleration) - if await self.get_is_shaking(): - break - if timeout is not None and time.time() - now > timeout: - raise TimeoutError("Failed to start shaking within timeout") - - async def shake( - self, - speed: float = 800, - direction: Literal[0, 1] = 0, - acceleration: int = 1_000, - timeout: Optional[float] = 30, - ): - warnings.warn( - "HamiltonHeaterShakerBackend.shake() is deprecated and will be removed in a future release. " - "Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking( - speed=speed, - direction=direction, - acceleration=acceleration, - timeout=timeout, - ) - - async def stop_shaking(self): - await self._stop_shaking() - await self._wait_for_stop() - - async def get_is_shaking(self) -> bool: - response = await self.interface.send_hhs_command(index=self.index, command="RD") - return response.endswith("1") # type: ignore[no-any-return] # what - - async def _move_plate_lock(self, position: PlateLockPosition): - return await self.interface.send_hhs_command(index=self.index, command="LP", lp=position.value) - - @property - def supports_locking(self) -> bool: - return True - - async def lock_plate(self): - await self._move_plate_lock(PlateLockPosition.LOCKED) - - async def unlock_plate(self): - await self._move_plate_lock(PlateLockPosition.UNLOCKED) - - async def _initialize_lock(self): - """Firmware command initialize lock.""" - return await self.interface.send_hhs_command(index=self.index, command="LI") - - async def _initialize_shaker_drive(self): - """Initialize the shaker drive, homing to absolute position 0""" - return await self.interface.send_hhs_command(index=self.index, command="SI") - - async def _start_shaking(self, direction: int, speed: int, acceleration: int): - """Firmware command for starting shaking.""" - speed_str = str(speed).zfill(4) - acceleration_str = str(acceleration).zfill(5) - return await self.interface.send_hhs_command( - index=self.index, command="SB", st=direction, sv=speed_str, sr=acceleration_str - ) - - async def _stop_shaking(self): - """Firmware command for stopping shaking.""" - return await self.interface.send_hhs_command(index=self.index, command="SC") - - async def _wait_for_stop(self): - """Firmware command for waiting for shaking to stop.""" - return await self.interface.send_hhs_command(index=self.index, command="SW") - - async def set_temperature(self, temperature: float): - """set temperature in Celsius""" - assert 0 < temperature <= 105 - temp_str = f"{round(10 * temperature):04d}" - return await self.interface.send_hhs_command(index=self.index, command="TA", ta=temp_str) - - async def _get_current_temperature(self) -> Dict[str, float]: - """get temperature in Celsius""" - response = await self.interface.send_hhs_command(index=self.index, command="RT") - response = response.split("rt")[1] - middle_temp = float(str(response).split(" ")[0].strip("+")) / 10 - edge_temp = float(str(response).split(" ")[1].strip("+")) / 10 - return {"middle": middle_temp, "edge": edge_temp} - - async def get_current_temperature(self) -> float: - """get temperature in Celsius""" - response = await self._get_current_temperature() - return response["middle"] - - async def get_edge_temperature(self) -> float: - """get temperature in Celsius""" - response = await self._get_current_temperature() - return response["edge"] - - async def deactivate(self): - """turn off heating""" - return await self.interface.send_hhs_command(index=self.index, command="TO") diff --git a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/heating_shaking/inheco/thermoshake_backend.py deleted file mode 100644 index 76efc556b36..00000000000 --- a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py +++ /dev/null @@ -1,89 +0,0 @@ -import warnings - -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.temperature_controlling.inheco.temperature_controller import ( - InhecoTemperatureControllerBackend, -) - - -class InhecoThermoshakeBackend(InhecoTemperatureControllerBackend, HeaterShakerBackend): - """Backend for Inheco Thermoshake devices - - https://www.inheco.com/thermoshake-ac.html - """ - - async def stop(self): - await self.stop_shaking() - await super().stop() - - async def _start_shaking_command(self): - """Send the device command that starts shaking with the configured settings.""" - - return await self.interface.send_command(f"{self.index}ASE1") - - async def stop_shaking(self): - """Stop shaking the device""" - - return await self.interface.send_command(f"{self.index}ASE0") - - async def set_shaker_speed(self, speed: float): - """Set the shaker speed on the device, but do not start shaking yet. Use `start_shaking` for - that. - """ - - # # 60 ... 2000 - # # Thermoshake and Teleshake - assert speed in range(60, 2001), "Speed must be in the range 60 to 2000 RPM" - - # Thermoshake AC, Teleshake95 AC and Teleshake AC - # 150 ... 3000 - # assert speed in range(150, 3001), "Speed must be in the range 150 to 3000 RPM" - - return await self.interface.send_command(f"1SSR{speed}") - - async def set_shaker_shape(self, shape: int): - """Set the shape of the figure that should be shaked. - - Args: - shape: 0 = Circle anticlockwise, 1 = Circle clockwise, 2 = Up left down right, 3 = Up right - down left, 4 = Up-down, 5 = Left-right - """ - - assert shape in range(6), "Shape must be in the range 0 to 5" - - return await self.interface.send_command(f"1SSS{shape}") - - async def start_shaking(self, speed: float, shape: int = 0): - """Start shaking at the given speed. - - Args: - speed: Speed of shaking in revolutions per minute (RPM) - """ - - 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, shape: int = 0): - """Deprecated alias for start_shaking.""" - warnings.warn( - "InhecoThermoshakeBackend.shake() is deprecated and will be removed in a future release. " - "Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed, shape=shape) - - @property - def supports_locking(self) -> bool: - return False - - async def lock_plate(self): - raise NotImplementedError( - "Locking the plate is not implemented yet for Inheco ThermoShake devices. " - ) - - async def unlock_plate(self): - raise NotImplementedError( - "Unlocking the plate is not implemented yet for Inheco ThermoShake devices. " - ) diff --git a/pylabrobot/inheco/__init__.py b/pylabrobot/inheco/__init__.py new file mode 100644 index 00000000000..c930f5d97f4 --- /dev/null +++ b/pylabrobot/inheco/__init__.py @@ -0,0 +1,9 @@ +from .control_box import InhecoTECControlBox +from .cpac import InhecoCPAC, InhecoCPACBackend, inheco_cpac_ultraflat +from .thermoshake import ( + InhecoThermoShake, + InhecoThermoshakeBackend, + inheco_thermoshake, + inheco_thermoshake_ac, + inheco_thermoshake_rm, +) diff --git a/pylabrobot/temperature_controlling/inheco/control_box.py b/pylabrobot/inheco/control_box.py similarity index 97% rename from pylabrobot/temperature_controlling/inheco/control_box.py rename to pylabrobot/inheco/control_box.py index d4699232797..3437ac864b6 100644 --- a/pylabrobot/temperature_controlling/inheco/control_box.py +++ b/pylabrobot/inheco/control_box.py @@ -12,7 +12,10 @@ def __init__( serial_number=None, ): self.io = HID( - human_readable_device_name="Inheco Control Box", vid=vid, pid=pid, serial_number=serial_number + human_readable_device_name="Inheco TEC Control Box", + vid=vid, + pid=pid, + serial_number=serial_number, ) async def setup(self): diff --git a/pylabrobot/inheco/cpac.py b/pylabrobot/inheco/cpac.py new file mode 100644 index 00000000000..2ea485782b8 --- /dev/null +++ b/pylabrobot/inheco/cpac.py @@ -0,0 +1,156 @@ +import abc +import logging +import warnings +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureController, + TemperatureControllerBackend, +) +from pylabrobot.device import Device, Driver +from pylabrobot.resources import Coordinate, ResourceHolder + +from .control_box import InhecoTECControlBox + +logger = logging.getLogger(__name__) + + +class InhecoTemperatureControllerBackend( + TemperatureControllerBackend, Driver, metaclass=abc.ABCMeta +): + """Universal backend for Inheco Temperature Controller devices such as ThermoShake and CPAC""" + + @property + def supports_active_cooling(self) -> bool: + return True + + def __init__(self, index: int, control_box: InhecoTECControlBox): + if not (1 <= index <= 6): + raise ValueError("Index must be between 1 and 6 (inclusive)") + self.index = index + self.interface = control_box + + async def setup(self, backend_params: Optional[BackendParams] = None): + pass + + async def stop(self): + await self.stop_temperature_control() + + def serialize(self) -> dict: + warnings.warn("The interface is not serialized.") + return super().serialize() + + # -- 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 deactivate(self): + logger.info("[Inheco idx=%d] deactivating temperature control", self.index) + await self.stop_temperature_control() + + # --- 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): + """Start the temperature control""" + return await self.interface.send_command(f"{self.index}ATE1") + + async def stop_temperature_control(self): + """Stop the temperature control""" + return await self.interface.send_command(f"{self.index}ATE0") + + # --- 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}") + + +class InhecoCPACBackend(InhecoTemperatureControllerBackend): + pass + + +class InhecoCPAC(ResourceHolder, Device): + """Inheco CPAC temperature controller. + + Example: + >>> from pylabrobot.inheco import InhecoCPAC, inheco_cpac_ultraflat + >>> cpac = inheco_cpac_ultraflat("cpac", control_box=box, index=1) + >>> await cpac.setup() + >>> await cpac.tc.set_temperature(37.0) + >>> await cpac.tc.get_temperature() + 37.0 + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + driver: InhecoCPACBackend, + 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, + ) + Device.__init__(self, driver=driver) + self.driver: InhecoCPACBackend = driver + self.tc = TemperatureController(backend=driver) + self._capabilities = [self.tc] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **ResourceHolder.serialize(self), + } + + +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 + """ + + return InhecoCPAC( + name=name, + driver=InhecoCPACBackend(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..8b35092daa8 --- /dev/null +++ b/pylabrobot/inheco/scila/__init__.py @@ -0,0 +1,3 @@ +from .inheco_sila_interface import InhecoSiLAInterface +from .scila import SCILA +from .scila_backend import DrawerStatus, SCILADriver, SCILATemperatureBackend diff --git a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py b/pylabrobot/inheco/scila/inheco_sila_interface.py similarity index 76% rename from pylabrobot/storage/inheco/scila/inheco_sila_interface.py rename to pylabrobot/inheco/scila/inheco_sila_interface.py index 86388ae337c..db663ed57cf 100644 --- a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py +++ b/pylabrobot/inheco/scila/inheco_sila_interface.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import datetime import http.server import logging import random @@ -13,13 +12,7 @@ from dataclasses import dataclass from typing import Any, Optional, Tuple -from pylabrobot.storage.inheco.scila.soap import ( - XSI, - _localname, - soap_body_payload, - soap_decode, - soap_encode, -) +from pylabrobot.inheco.scila.soap import XSI, soap_decode, soap_encode SOAP_RESPONSE_ResponseEventResponse = """ """ -SOAP_RESPONSE_DataEventResponse = """ - - - - 1 - Success - PT0S - 0 - - - -""" - - def _get_local_ip(machine_ip: str) -> str: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: @@ -85,7 +63,6 @@ def __init__(self, code: int, message: str, command: str, details: Optional[dict self.message = message self.command = command self.details = details or {} - super().__init__(f"Command {command} failed with code {code}: '{message}'") class InhecoSiLAInterface: @@ -227,50 +204,33 @@ async def _on_http(self, req: _HTTPRequest) -> bytes: cmd = self._pending - try: - xml_str = req.body.decode("utf-8") - payload = soap_body_payload(xml_str) - tag_local = _localname(payload.tag) - - if cmd is not None and not cmd.fut.done() and tag_local == "ResponseEvent": - response_event = soap_decode(xml_str) - if response_event["ResponseEvent"].get("requestId") == cmd.request_id: - ret = response_event["ResponseEvent"].get("returnValue", {}) - rc = ret.get("returnCode") - if rc != 3: # 3=Success + 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( - SiLAError(rc, ret.get("message", "").replace(chr(10), " "), cmd.name, details=ret) + RuntimeError(f"Command {cmd.name} failed with code {return_code}: '{err_msg}'") ) else: - cmd.fut.set_result( - ET.fromstring(d) - if (d := response_event["ResponseEvent"].get("responseData")) - else ET.Element("EmptyResponse") - ) - - if tag_local == "DataEvent": - try: - raw = next(e.text for e in payload.iter() if _localname(e.tag) == "dataValue") - any_data_elem = ET.fromstring(raw).find(".//AnyData") # type: ignore[arg-type] - assert any_data_elem is not None and any_data_elem.text is not None - series = ET.fromstring(any_data_elem.text).findall(".//dataSeries") - data = {} - for s in series: - val = s.findall(".//integerValue")[-1].text - unit = s.get("unit") - data[s.get("nameId")] = f"{val} {unit}" if unit else val - print(f"[{datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3]}] [SiLA DataEvent] {data}") - except Exception: - pass - return SOAP_RESPONSE_DataEventResponse.encode("utf-8") - - if tag_local == "StatusEvent": - return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") - return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + 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.") - except Exception as e: - self._logger.error(f"Error handling event: {e}") + 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 diff --git a/pylabrobot/inheco/scila/scila.py b/pylabrobot/inheco/scila/scila.py new file mode 100644 index 00000000000..2d2cc80ce37 --- /dev/null +++ b/pylabrobot/inheco/scila/scila.py @@ -0,0 +1,87 @@ +import logging +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray import LoadingTray +from pylabrobot.capabilities.loading_tray.backend import LoadingTrayBackend +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Resource + +from .scila_backend import SCILADriver, SCILATemperatureBackend + +logger = logging.getLogger(__name__) + + +class SCILADrawerLoadingTrayBackend(LoadingTrayBackend): + """Loading tray backend for a single SCILA drawer.""" + + def __init__(self, driver: SCILADriver, drawer_id: int): + if drawer_id not in {1, 2, 3, 4}: + raise ValueError(f"Invalid drawer ID: {drawer_id}. Must be 1, 2, 3, or 4.") + self._driver = driver + self._drawer_id = drawer_id + + async def open(self, backend_params: Optional[BackendParams] = None): + await self._driver.send_command("PrepareForInput", position=self._drawer_id) + try: + await self._driver.send_command("OpenDoor") + except RuntimeError as e: + # SCILA raises a non-fatal CO2-flow warning as an exception; log and continue. + if "warning" not in str(e).lower(): + raise + logger.warning("drawer %d open: %s", self._drawer_id, e) + + async def close(self, backend_params: Optional[BackendParams] = None): + await self._driver.send_command("PrepareForOutput", position=self._drawer_id) + try: + await self._driver.send_command("CloseDoor") + except RuntimeError as e: + # SCILA raises a non-fatal CO2-flow warning as an exception; log and continue. + if "warning" not in str(e).lower(): + raise + logger.warning("drawer %d close: %s", self._drawer_id, e) + + +class SCILA(Resource, Device): + """Inheco SCILA incubator with 4 drawers and temperature control.""" + + def __init__( + self, + name: str, + scila_ip: str, + client_ip: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Inheco SCILA", + ) + Device.__init__(self, driver=driver) + self.driver: SCILADriver = driver + self.tc = TemperatureController(backend=SCILATemperatureBackend(driver=driver)) + + self.drawers: Dict[int, LoadingTray] = {} + for drawer_id in range(1, 5): + tray = LoadingTray( + backend=SCILADrawerLoadingTrayBackend(driver=driver, 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 + + self._capabilities = [self.tc, *self.drawers.values()] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/storage/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py similarity index 55% rename from pylabrobot/storage/inheco/scila/scila_backend.py rename to pylabrobot/inheco/scila/scila_backend.py index 4f046ed67ff..9cae4771a31 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -1,8 +1,14 @@ +import logging 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.capabilities.capability import BackendParams +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver + +from .inheco_sila_interface import InhecoSiLAInterface + +logger = logging.getLogger(__name__) def _parse_scalar(text: Optional[str], tag: str) -> object: @@ -16,14 +22,14 @@ def _parse_scalar(text: Optional[str], tag: str) -> object: return int(s) if t in ("boolean", "bool"): return s.lower() == "true" - return s # String or unknown => raw text + 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)) # e.g. , , + child = next(iter(p)) return _parse_scalar(child.text, child.tag) @@ -34,62 +40,51 @@ def _get_params(root: ET.Element, names: list[str]) -> dict[str, object]: DrawerStatus = Literal["Opened", "Closed"] -class SCILABackend(MachineBackend): +class SCILADriver(Driver): + """Hardware driver for Inheco SCILA incubators. + + Owns the SiLA HTTP/SOAP connection and exposes generic send_command(), + plus device-level operations (drawers, status, CO2/valves). + """ + def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: + super().__init__() self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=scila_ip) - async def setup(self) -> None: + async def setup(self, backend_params: Optional[BackendParams] = None) -> 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._sila_interface.send_command( + await self.send_command( command="Reset", deviceId="MyController", eventReceiverURI=event_uri, simulationMode=False ) + await self.send_command("Initialize") - await self._sila_interface.send_command("Initialize") + # -- status queries -- async def request_status(self) -> str: - # GetStatus returns synchronously (return_code 1 = immediate dict), unlike other commands - # which return asynchronously (return_code 2 = XML via callback). - resp = await self._sila_interface.send_command("GetStatus") + 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._sila_interface.send_command("GetLiquidLevel") + root = await self.send_command("GetLiquidLevel") return _get_param(root, "LiquidLevel") # type: ignore - async def request_temperature_information(self) -> dict[str, Any]: - root = await self._sila_interface.send_command("GetTemperature") - return _get_params(root, ["CurrentTemperature", "TargetTemperature", "TemperatureControl"]) # type: ignore - - async def measure_temperature(self) -> float: - return (await self.request_temperature_information())["CurrentTemperature"] # type: ignore - - 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 - - async def open(self, drawer_id: int) -> 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.") - await self._sila_interface.send_command("PrepareForInput", position=drawer_id) - await self._sila_interface.send_command("OpenDoor") - - async def close(self, drawer_id: int) -> 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.") - await self._sila_interface.send_command("PrepareForOutput", position=drawer_id) - await self._sila_interface.send_command("CloseDoor") + # -- drawers -- async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: - root = await self._sila_interface.send_command("GetDoorStatus") + 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 @@ -99,31 +94,17 @@ async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: positions = await self.request_drawer_statuses() return positions[drawer_id] + # -- CO2 / valves -- + async def request_co2_flow_status(self) -> str: - root = await self._sila_interface.send_command("GetCO2FlowStatus") + root = await self.send_command("GetCO2FlowStatus") return _get_param(root, "CO2FlowStatus") # type: ignore async def request_valve_status(self) -> dict[str, str]: - """ - example: - - { - "H2O": "Opened", - "CO2 Normal": "Opened", - "CO2 Boost": "Closed" - } - """ - - root = await self._sila_interface.send_command("GetValveStatus") + root = await self.send_command("GetValveStatus") return _get_params(root, ["H2O", "CO2 Normal", "CO2 Boost"]) # type: ignore - async def start_temperature_control(self, temperature: float) -> None: - await self._sila_interface.send_command( - "SetTemperature", targetTemperature=temperature, temperatureControl=True - ) - - async def stop_temperature_control(self) -> None: - await self._sila_interface.send_command("SetTemperature", temperatureControl=False) + # -- serialization -- def serialize(self) -> dict[str, Any]: return { @@ -132,6 +113,44 @@ def serialize(self) -> dict[str, Any]: "client_ip": self._sila_interface.client_ip, } - @classmethod - def deserialize(cls, data: dict[str, Any]) -> "SCILABackend": - return cls(scila_ip=data["scila_ip"], client_ip=data.get("client_ip")) + +class SCILATemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend interface into SCILA SiLA commands.""" + + def __init__(self, driver: SCILADriver) -> None: + self.driver = driver + + @property + def supports_active_cooling(self) -> bool: + return False + + async def request_temperature_information(self) -> dict[str, Any]: + root = await self.driver.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.driver._sila_interface.machine_ip, + temperature, + ) + await self.driver.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.driver._sila_interface.machine_ip, temp + ) + return temp + + async def deactivate(self) -> None: + logger.info("[SCILA %s] deactivate temperature control", self.driver._sila_interface.machine_ip) + await self.driver.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 diff --git a/pylabrobot/inheco/scila/scila_backend_tests.py b/pylabrobot/inheco/scila/scila_backend_tests.py new file mode 100644 index 00000000000..405d6fc8021 --- /dev/null +++ b/pylabrobot/inheco/scila/scila_backend_tests.py @@ -0,0 +1,268 @@ +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import AsyncMock, patch + +from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.inheco.scila.scila import SCILADrawerLoadingTrayBackend +from pylabrobot.inheco.scila.scila_backend import SCILADriver, SCILATemperatureBackend + + +class TestSCILADriver(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.patcher = patch("pylabrobot.inheco.scila.scila_backend.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.driver = SCILADriver(scila_ip="127.0.0.1") + + def tearDown(self): + self.patcher.stop() + + async def test_setup(self): + await self.driver.setup() + self.mock_sila_interface.setup.assert_called_once() + self.mock_sila_interface.send_command.assert_any_call( + "Reset", + deviceId="MyController", + eventReceiverURI="http://127.0.0.1:80/", + simulationMode=False, + ) + self.mock_sila_interface.send_command.assert_any_call("Initialize") + + async def test_stop(self): + await self.driver.setup() + await self.driver.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"}} + status = await self.driver.request_status() + self.assertEqual(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( + "High" + ) + level = await self.driver.request_liquid_level() + self.assertEqual(level, "High") + 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( + "" + " Opened" + " Closed" + " Opened" + " Closed" + "" + ) + positions = await self.driver.request_drawer_statuses() + self.assertEqual( + positions, + { + 1: "Opened", + 2: "Closed", + 3: "Opened", + 4: "Closed", + }, + ) + self.mock_sila_interface.send_command.assert_called_with("GetDoorStatus") + + async def test_request_drawer_status_single(self): + for drawer_id, expected_position in [ + (1, "Opened"), + (2, "Closed"), + (3, "Opened"), + (4, "Closed"), + ]: + with self.subTest(drawer_id=drawer_id): + self.mock_sila_interface.send_command.reset_mock() + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Opened" + " Closed" + " Opened" + " Closed" + "" + ) + position = await self.driver.request_drawer_status(drawer_id) + self.assertEqual(position, expected_position) + + async def test_request_drawer_status_invalid_id(self): + with self.assertRaises(ValueError): + await self.driver.request_drawer_status(5) + + async def test_request_co2_flow_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "OK" + ) + status = await self.driver.request_co2_flow_status() + self.assertEqual(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( + "" + " Opened" + " Opened" + " Closed" + "" + ) + status = await self.driver.request_valve_status() + self.assertEqual( + status, + { + "H2O": "Opened", + "CO2 Normal": "Opened", + "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.driver.serialize() + self.assertEqual(data["scila_ip"], "169.254.1.117") + self.assertEqual(data["client_ip"], "192.168.1.10") + + 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.driver.serialize() + self.assertEqual(data["scila_ip"], "127.0.0.1") + self.assertIsNone(data["client_ip"]) + + def test_deserialize(self): + data = {"type": "SCILADriver", "scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} + SCILADriver.deserialize(data) + self.MockInhecoSiLAInterface.assert_called_with( + client_ip="192.168.1.10", machine_ip="169.254.1.117" + ) + + def test_deserialize_no_client_ip(self): + data = {"type": "SCILADriver", "scila_ip": "169.254.1.117"} + SCILADriver.deserialize(data) + self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117") + + +class TestSCILATemperatureBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.patcher = patch("pylabrobot.inheco.scila.scila_backend.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.driver = SCILADriver(scila_ip="127.0.0.1") + self.backend = SCILATemperatureBackend(driver=self.driver) + + def tearDown(self): + self.patcher.stop() + + async def test_request_temperature_information(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + info = await self.backend.request_temperature_information() + self.assertEqual( + info, {"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( + "" + " 25.0" + " 37.0" + " true" + "" + ) + temp = await self.backend.request_current_temperature() + self.assertEqual(temp, 25.0) + + async def test_request_target_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + temp = await self.backend.request_target_temperature() + self.assertEqual(temp, 37.0) + + async def test_is_temperature_control_enabled(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + enabled = await self.backend.is_temperature_control_enabled() + self.assertIs(enabled, True) + + async def test_set_temperature(self): + await self.backend.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.backend.deactivate() + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", temperatureControl=False + ) + + def test_supports_active_cooling(self): + self.assertFalse(self.backend.supports_active_cooling) + + +class TestSCILADrawerLoadingTrayBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.patcher = patch("pylabrobot.inheco.scila.scila_backend.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.driver = SCILADriver(scila_ip="127.0.0.1") + + def tearDown(self): + self.patcher.stop() + + 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() + backend = SCILADrawerLoadingTrayBackend(driver=self.driver, drawer_id=drawer_id) + await backend.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() + backend = SCILADrawerLoadingTrayBackend(driver=self.driver, drawer_id=drawer_id) + await backend.close() + self.mock_sila_interface.send_command.assert_any_call( + "PrepareForOutput", position=drawer_id + ) + self.mock_sila_interface.send_command.assert_any_call("CloseDoor") + + def test_invalid_drawer_id(self): + with self.assertRaises(ValueError): + SCILADrawerLoadingTrayBackend(driver=self.driver, drawer_id=5) + + +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/thermoshake.py b/pylabrobot/inheco/thermoshake.py new file mode 100644 index 00000000000..bdd9ba2a792 --- /dev/null +++ b/pylabrobot/inheco/thermoshake.py @@ -0,0 +1,184 @@ +import asyncio +import logging +from typing import Optional + +from pylabrobot.capabilities.shaking import Shaker, ShakerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, ResourceHolder + +from .control_box import InhecoTECControlBox +from .cpac import InhecoTemperatureControllerBackend + +logger = logging.getLogger(__name__) + + +class InhecoThermoshakeBackend( + InhecoTemperatureControllerBackend, ShakerBackend, HasContinuousShaking +): + """Backend for Inheco Thermoshake devices. + + https://www.inheco.com/thermoshake-ac.html + """ + + async def stop(self): + await self.stop_shaking() + await super().stop() + + 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, backend_params=None): + await self.start_shaking(speed=speed) + try: + await asyncio.sleep(duration) + finally: + await self.stop_shaking() + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("Locking is not supported on Inheco ThermoShake devices.") + + async def unlock_plate(self): + raise NotImplementedError("Unlocking is not supported on Inheco ThermoShake devices.") + + +class InhecoThermoShake(ResourceHolder, Device): + """Inheco ThermoShake: combined temperature control and shaking. + + Example: + >>> from pylabrobot.inheco import InhecoThermoShake, inheco_thermoshake + >>> ts = inheco_thermoshake("ts", control_box=box, index=1) + >>> await ts.setup() + >>> await ts.tc.set_temperature(37.0) + >>> await ts.shaking.shake(speed=300) + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + driver: InhecoThermoshakeBackend, + 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, + ) + Device.__init__(self, driver=driver) + self.driver: InhecoThermoshakeBackend = driver + self.tc = TemperatureController(backend=driver) + self.shaker = Shaker(backend=driver) + self._capabilities = [self.tc, self.shaker] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **ResourceHolder.serialize(self), + } + + +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, + driver=InhecoThermoshakeBackend(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, + driver=InhecoThermoshakeBackend(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, + driver=InhecoThermoshakeBackend(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/io/ftdi.py b/pylabrobot/io/ftdi.py index e33b48e1b54..ee967cc8ce2 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -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): diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 914d4567d19..b8b5c085071 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,6 +49,7 @@ def __init__( timeout=1, rtscts: bool = False, dsrdtr: bool = False, + xonxoff: bool = False, ): self._human_readable_device_name = human_readable_device_name self._port = port @@ -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: @@ -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/socket.py b/pylabrobot/io/socket.py index 0fcae09a6bd..575cb4d7c1d 100644 --- a/pylabrobot/io/socket.py +++ b/pylabrobot/io/socket.py @@ -99,20 +99,6 @@ 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. diff --git a/pylabrobot/io/usb.py b/pylabrobot/io/usb.py index 14d2802f986..a0b4bcba101 100644 --- a/pylabrobot/io/usb.py +++ b/pylabrobot/io/usb.py @@ -450,10 +450,10 @@ async def setup(self, empty_buffer=True): self._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 diff --git a/pylabrobot/io/validation.py b/pylabrobot/io/validation.py index 01d9d749e83..97fff0fccb4 100644 --- a/pylabrobot/io/validation.py +++ b/pylabrobot/io/validation.py @@ -1,11 +1,11 @@ from typing import Optional +from pylabrobot.device import Driver from pylabrobot.io.capture import CaptureReader, capturer from pylabrobot.io.ftdi import FTDI, FTDIValidator 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,7 +40,7 @@ def _replace_io(obj): return False return True - for machine_backend in MachineBackend.get_all_instances(): + for machine_backend in Driver.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)) diff --git a/pylabrobot/keyence/__init__.py b/pylabrobot/keyence/__init__.py new file mode 100644 index 00000000000..94a30681cd9 --- /dev/null +++ b/pylabrobot/keyence/__init__.py @@ -0,0 +1,5 @@ +from .keyence_backend import ( + KeyenceBarcodeScannerBarcodeScanningBackend, + KeyenceBarcodeScannerDriver, +) +from .keyence_barcode_scanner import KeyenceBarcodeScanner diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/keyence/keyence_backend.py similarity index 53% rename from pylabrobot/barcode_scanners/keyence/keyence_backend.py rename to pylabrobot/keyence/keyence_backend.py index e79501fda71..61a31f165c9 100644 --- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py +++ b/pylabrobot/keyence/keyence_backend.py @@ -10,26 +10,30 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.barcode_scanners.backend import ( +from typing import Optional + +from pylabrobot.capabilities.barcode_scanning.backend import ( BarcodeScannerBackend, BarcodeScannerError, ) +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources.barcode import Barcode logger = logging.getLogger(__name__) -class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): +class KeyenceBarcodeScannerDriver(Driver): + """Serial driver for Keyence BL-series barcode scanners. + + Owns the serial connection and provides a generic send_command() method. + """ + default_baudrate = 9600 serial_messaging_encoding = "ascii" - init_timeout = 1.0 # seconds - poll_interval = 0.2 # seconds - def __init__( - self, - port: str, - ): + def __init__(self, port: str): if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " @@ -51,18 +55,42 @@ def __init__( rtscts=False, ) - async def setup(self): + async def setup(self, backend_params: Optional[BackendParams] = None): await self.io.setup() - await self.initialize() + logger.info("[Keyence %s] connected", self.io.port) + + 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 initialize(self): - """Initialize the Keyence barcode scanner.""" +class KeyenceBarcodeScannerBarcodeScanningBackend(BarcodeScannerBackend): + """Translates BarcodeScannerBackend interface into Keyence driver commands.""" + + init_timeout = 1.0 # seconds + poll_interval = 0.2 # seconds + + def __init__(self, driver: KeyenceBarcodeScannerDriver): + super().__init__() + self.driver = driver + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + """Initialize the barcode scanner motor after the driver connects.""" deadline = time.time() + self.init_timeout while time.time() < deadline: - response = await self.send_command("RMOTOR") + response = await self.driver.send_command("RMOTOR") if response.strip() == "MOTORON": - logger.info("Barcode scanner motor is ON.") + logger.info("[Keyence %s] barcode scanner motor is ON", self.driver.io.port) break elif response.strip() == "MOTOROFF": raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: Motor is off.") @@ -72,21 +100,17 @@ async def initialize(self): "Failed to initialize Keyence barcode scanner: Timeout waiting for motor to turn on." ) - 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() - return response.decode(self.serial_messaging_encoding).strip() - - async def stop(self): - await self.io.stop() - - async def scan_barcode(self) -> Barcode: - data = await self.send_command("LON") + 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.driver.send_command("LON") if data.startswith("NG"): + logger.error("[Keyence %s] barcode reader is off: cannot read barcode", self.driver.io.port) raise BarcodeScannerError("Barcode reader is off: cannot read barcode") if data.startswith("ERR99"): + logger.error("[Keyence %s] barcode reader error: %s", self.driver.io.port, data) raise BarcodeScannerError(f"Error response from barcode reader: {data}") + logger.info("[Keyence %s] scanned barcode: %s", self.driver.io.port, data) return Barcode(data=data, symbology="unknown", position_on_resource="front") diff --git a/pylabrobot/keyence/keyence_barcode_scanner.py b/pylabrobot/keyence/keyence_barcode_scanner.py new file mode 100644 index 00000000000..8355ee48d71 --- /dev/null +++ b/pylabrobot/keyence/keyence_barcode_scanner.py @@ -0,0 +1,20 @@ +from pylabrobot.capabilities.barcode_scanning import BarcodeScanner +from pylabrobot.device import Device + +from .keyence_backend import ( + KeyenceBarcodeScannerBarcodeScanningBackend, + KeyenceBarcodeScannerDriver, +) + + +class KeyenceBarcodeScanner(Device): + """Keyence BL-series barcode scanner (BL-600HA, BL-1300).""" + + def __init__(self, port: str): + driver = KeyenceBarcodeScannerDriver(port=port) + super().__init__(driver=driver) + self.driver: KeyenceBarcodeScannerDriver = driver + self.barcode_scanning = BarcodeScanner( + backend=KeyenceBarcodeScannerBarcodeScanningBackend(driver) + ) + self._capabilities = [self.barcode_scanning] diff --git a/pylabrobot/legacy/__init__.py b/pylabrobot/legacy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/legacy/_backend_params.py b/pylabrobot/legacy/_backend_params.py new file mode 100644 index 00000000000..7113aa28e76 --- /dev/null +++ b/pylabrobot/legacy/_backend_params.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass, field +from typing import Any, Dict + +from pylabrobot.capabilities.capability import BackendParams + + +@dataclass +class _DictBackendParams(BackendParams): + """Wraps legacy **backend_kwargs into a BackendParams for the new capability interface.""" + + kwargs: Dict[str, Any] = field(default_factory=dict) diff --git a/pylabrobot/legacy/arms/__init__.py b/pylabrobot/legacy/arms/__init__.py new file mode 100644 index 00000000000..60125af2e6e --- /dev/null +++ b/pylabrobot/legacy/arms/__init__.py @@ -0,0 +1,3 @@ +from .precise_flex import * +from .scara import * +from .standard import * diff --git a/pylabrobot/legacy/arms/backend.py b/pylabrobot/legacy/arms/backend.py new file mode 100644 index 00000000000..39f0feea437 --- /dev/null +++ b/pylabrobot/legacy/arms/backend.py @@ -0,0 +1,139 @@ +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from typing import Dict, List, Optional, Union + +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.machines.backend import MachineBackend + + +@dataclass +class VerticalAccess: + """Access location from above (most common pattern for stacks and tube racks). + + This access pattern is used when approaching a location from above, such as + picking from a plate stack or tube rack on the deck. + + Args: + approach_height_mm: Height above the target position to move to before descending to grip (default: 100mm) + clearance_mm: Vertical distance to retract after gripping before lateral movement (default: 100mm) + gripper_offset_mm: Additional vertical offset added when holding a plate, accounts for gripper thickness (default: 10mm) + """ + + approach_height_mm: float = 100 + clearance_mm: float = 100 + gripper_offset_mm: float = 10 + + +@dataclass +class HorizontalAccess: + """Access location from the side (for hotel-style plate carriers). + + This access pattern is used when approaching a location horizontally, such as + accessing plates in a hotel-style storage system. + + Args: + approach_distance_mm: Horizontal distance in front of the target to stop before moving in to grip (default: 50mm) + clearance_mm: Horizontal distance to retract after gripping before lifting (default: 50mm) + lift_height_mm: Vertical distance to lift the plate after horizontal retract, before lateral movement (default: 100mm) + gripper_offset_mm: Additional vertical offset added when holding a plate, accounts for gripper thickness (default: 10mm) + """ + + approach_distance_mm: float = 50 + clearance_mm: float = 50 + lift_height_mm: float = 100 + gripper_offset_mm: float = 10 + + +AccessPattern = Union[VerticalAccess, HorizontalAccess] + + +class SCARABackend(MachineBackend, metaclass=ABCMeta): + """Backend for a robotic arm""" + + @abstractmethod + async def open_gripper(self, gripper_width: float) -> None: + """Open the arm's gripper.""" + + @abstractmethod + async def close_gripper(self, gripper_width: float) -> None: + """Close the arm's gripper.""" + + @abstractmethod + async def is_gripper_closed(self) -> bool: + """Check if the gripper is currently closed.""" + + @abstractmethod + async def halt(self) -> None: + """Stop any ongoing movement of the arm.""" + + @abstractmethod + async def home(self) -> None: + """Home the arm to its default position.""" + + @abstractmethod + async def move_to_safe(self) -> None: + """Move the arm to a predefined safe position.""" + + @abstractmethod + async def approach( + self, + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], + access: Optional[AccessPattern] = None, + ) -> None: + """Move the arm to an approach position (offset from target). + + Args: + position: Target position (CartesianCoords or joint position dict) + access: Access pattern defining how to approach the target. Defaults to VerticalAccess() if not specified. + """ + + @abstractmethod + async def pick_up_resource( + self, + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], + plate_width: float, + access: Optional[AccessPattern] = None, + ) -> None: + """Pick a plate from the specified position. + + Args: + position: Target position for pickup + access: Access pattern defining how to approach and retract. Defaults to VerticalAccess() if not specified. + """ + + @abstractmethod + async def drop_resource( + self, + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], + access: Optional[AccessPattern] = None, + ) -> None: + """Place a plate at the specified position. + + Args: + position: Target position for placement + access: Access pattern defining how to approach and retract. Defaults to VerticalAccess() if not specified. + """ + + @abstractmethod + async def move_to(self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]]) -> None: + """Move the arm to a specified position in 3D space or in joint space.""" + + @abstractmethod + async def get_joint_position(self) -> Dict[int, float]: + """Get the current position of the arm in joint space.""" + + @abstractmethod + async def get_cartesian_position(self) -> PreciseFlexCartesianCoords: + """Get the current position of the arm in 3D space.""" + + @abstractmethod + async def freedrive_mode(self, free_axes: List[int]) -> None: + """Enter freedrive mode, allowing manual movement of the specified joints. + + Args: + free_axes: List of joint indices to free. + """ + + @abstractmethod + async def end_freedrive_mode(self) -> None: + """Exit freedrive mode.""" 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/legacy/arms/precise_flex/pf_3400.py b/pylabrobot/legacy/arms/precise_flex/pf_3400.py new file mode 100644 index 00000000000..8095ef2fc36 --- /dev/null +++ b/pylabrobot/legacy/arms/precise_flex/pf_3400.py @@ -0,0 +1,14 @@ +"""Legacy. Use pylabrobot.brooks.PreciseFlex3400Backend instead.""" + +from pylabrobot.brooks.precise_flex import PreciseFlex3400Backend as _NewBackend +from pylabrobot.brooks.precise_flex import PreciseFlexDriver +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend + + +class PreciseFlex3400Backend(PreciseFlexBackend): + """Legacy. Use pylabrobot.brooks.PreciseFlex3400Backend instead.""" + + def __init__(self, host: str, port: int = 10100, has_rail: bool = False, timeout=20) -> None: + super().__init__(host=host, port=port, has_rail=has_rail, timeout=timeout) + self._new_driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + self._new_backend = _NewBackend(driver=self._new_driver, has_rail=has_rail) diff --git a/pylabrobot/legacy/arms/precise_flex/pf_400.py b/pylabrobot/legacy/arms/precise_flex/pf_400.py new file mode 100644 index 00000000000..13bc3142a3b --- /dev/null +++ b/pylabrobot/legacy/arms/precise_flex/pf_400.py @@ -0,0 +1,15 @@ +"""Legacy. Use pylabrobot.brooks.PreciseFlex400 instead.""" + +from pylabrobot.brooks.precise_flex import PreciseFlexArmBackend, PreciseFlexDriver +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend + + +class PreciseFlex400Backend(PreciseFlexBackend): + """Legacy. Use pylabrobot.brooks.PreciseFlex400 instead.""" + + def __init__(self, host: str, port: int = 10100, has_rail: bool = False, timeout=20) -> None: + super().__init__(host=host, port=port, has_rail=has_rail, timeout=timeout) + self._new_driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + self._new_backend = PreciseFlexArmBackend( + driver=self._new_driver, has_rail=has_rail, gripper_length=162.0, gripper_z_offset=0.0 + ) diff --git a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py new file mode 100644 index 00000000000..1f68d47e04a --- /dev/null +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -0,0 +1,803 @@ +"""Legacy. Use pylabrobot.brooks instead.""" + +import warnings +from abc import ABC +from typing import Dict, List, Optional, Union + +from pylabrobot.brooks import precise_flex as _new_module +from pylabrobot.io.socket import Socket +from pylabrobot.legacy.arms.backend import ( + AccessPattern, + HorizontalAccess, + SCARABackend, + VerticalAccess, +) +from pylabrobot.legacy.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords +from pylabrobot.resources import Coordinate, Rotation + +PreciseFlexError = _new_module.PreciseFlexError + + +def _to_new_cartesian( + position: PreciseFlexCartesianCoords, +) -> _new_module.PreciseFlexCartesianPose: + """Convert legacy CartesianCoords to new module's CartesianPose.""" + return _new_module.PreciseFlexCartesianPose( + location=position.location, + rotation=position.rotation, + orientation=position.orientation.value if position.orientation is not None else None, + ) + + +def _to_new_coords( + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], +) -> Union[_new_module.PreciseFlexCartesianPose, Dict[int, float]]: + """Convert legacy CartesianCoords to new module's CartesianCoords.""" + if isinstance(position, PreciseFlexCartesianCoords): + return _to_new_cartesian(position) + return position + + +def _to_new_access(access: Optional[AccessPattern]) -> Optional[_new_module.AccessPattern]: + """Convert legacy AccessPattern to new module's AccessPattern.""" + if access is None: + return None + if isinstance(access, VerticalAccess): + return _new_module.VerticalAccess( + approach_height_mm=access.approach_height_mm, + clearance_mm=access.clearance_mm, + gripper_offset_mm=access.gripper_offset_mm, + ) + if isinstance(access, HorizontalAccess): + return _new_module.HorizontalAccess( + approach_distance_mm=access.approach_distance_mm, + clearance_mm=access.clearance_mm, + lift_height_mm=access.lift_height_mm, + gripper_offset_mm=access.gripper_offset_mm, + ) + return None + + +def _from_new_coords( + position: _new_module.PreciseFlexCartesianPose, +) -> PreciseFlexCartesianCoords: + """Convert new module's CartesianPose to legacy CartesianCoords.""" + orientation = None + if position.orientation is not None: + orientation = ElbowOrientation(position.orientation) + return PreciseFlexCartesianCoords( + location=position.location, + rotation=position.rotation, + orientation=orientation, + ) + + +class PreciseFlexBackend(SCARABackend, ABC): + """Legacy. Use pylabrobot.brooks.PreciseFlexArmBackend instead.""" + + def __init__( + self, + host: str, + port: int = 10100, + is_dual_gripper: bool = False, + has_rail: bool = False, + timeout=20, + ) -> None: + super().__init__() + self._new_driver = _new_module.PreciseFlexDriver(host=host, port=port, timeout=timeout) + self._new_backend = _new_module.PreciseFlexArmBackend( + driver=self._new_driver, + is_dual_gripper=is_dual_gripper, + has_rail=has_rail, + gripper_length=162.0, + gripper_z_offset=0.0, + ) + # Keep these for any legacy code that accesses them directly + self.io = Socket(human_readable_device_name="Precise Flex Arm", host=host, port=port) + self.profile_index: int = 1 + self.location_index: int = 1 + self.horizontal_compliance: bool = False + self.horizontal_compliance_torque: int = 0 + self.timeout = timeout + self._has_rail = has_rail + self._is_dual_gripper = is_dual_gripper + if is_dual_gripper: + warnings.warn( + "Dual gripper support is experimental and may not work as expected.", UserWarning + ) + + def _convert_to_cartesian_space( + self, position: tuple[float, float, float, float, float, float, Optional[ElbowOrientation]] + ) -> PreciseFlexCartesianCoords: + if len(position) != 7: + raise ValueError( + "Position must be a tuple of 7 values (x, y, z, yaw, pitch, roll, orientation)." + ) + orientation = ElbowOrientation(position[6]) + return PreciseFlexCartesianCoords( + location=Coordinate(position[0], position[1], position[2]), + rotation=Rotation(position[5], position[4], position[3]), + orientation=orientation, + ) + + def _convert_to_cartesian_array( + self, position: PreciseFlexCartesianCoords + ) -> tuple[float, float, float, float, float, float, int]: + orientation_int = self._convert_orientation_enum_to_int(position.orientation) + arr = ( + position.location.x, + position.location.y, + position.location.z, + position.rotation.yaw, + position.rotation.pitch, + position.rotation.roll, + orientation_int, + ) + return arr + + async def setup(self, skip_home: bool = False): + from pylabrobot.brooks.precise_flex import PreciseFlexDriver + + await self._new_driver.setup(backend_params=PreciseFlexDriver.SetupParams(skip_home=skip_home)) + + async def stop(self): + await self._new_driver.stop() + + async def set_speed(self, speed_percent: float): + await self._new_backend._set_speed(speed_percent) + + async def get_speed(self) -> float: + return await self._new_backend._request_speed() + + async def open_gripper(self, gripper_width: float): + await self._new_backend.open_gripper(gripper_width) + + async def close_gripper(self, gripper_width: float): + await self._new_backend.close_gripper(gripper_width) + + async def halt(self): + await self._new_backend.halt() + + async def home(self) -> None: + await self._new_driver.home() + + async def move_to_safe(self) -> None: + await self._new_backend.park() + + def _convert_orientation_int_to_enum(self, orientation_int: int) -> Optional[ElbowOrientation]: + if orientation_int == 1: + return ElbowOrientation.RIGHT + if orientation_int == 2: + return ElbowOrientation.LEFT + return None + + def _convert_orientation_enum_to_int(self, orientation: Optional[ElbowOrientation]) -> int: + if orientation == ElbowOrientation.LEFT: + return 2 + if orientation == ElbowOrientation.RIGHT: + return 1 + return 0 + + async def home_all(self) -> None: + await self._new_driver.home_all() + + async def attach(self, attach_state: Optional[int] = None) -> int: + return await self._new_driver.attach(attach_state) + + async def detach(self): + await self._new_driver.detach() + + async def power_on_robot(self): + await self._new_driver.power_on_robot() + + async def power_off_robot(self): + await self._new_driver.power_off_robot() + + async def approach( + self, + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], + access: Optional[AccessPattern] = None, + ): + await self._new_backend.approach(_to_new_coords(position), _to_new_access(access)) + + async def pick_up_resource( + self, + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], + plate_width: float, + access: Optional[AccessPattern] = None, + finger_speed_percent: float = 50.0, + grasp_force: float = 10.0, + ): + converted = _to_new_coords(position) + params = _new_module.PreciseFlexArmBackend.PickUpParams( + access=_to_new_access(access), + finger_speed_percent=finger_speed_percent, + grasp_force=grasp_force, + ) + if isinstance(converted, dict): + await self._new_backend.pick_up_at_joint_position( + converted, plate_width, backend_params=params + ) + elif isinstance(converted, _new_module.PreciseFlexCartesianPose): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + await self._new_backend._set_grasp_data( + plate_width=plate_width, + finger_speed_percent=finger_speed_percent, + grasp_force=grasp_force, + ) + await self._new_backend._pick_plate_c(cartesian_position=converted, access=new_access) + else: + raise TypeError("Position must be of type Dict[int, float] or PreciseFlexCartesianPose.") + + async def drop_resource( + self, + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], + access: Optional[AccessPattern] = None, + ): + converted = _to_new_coords(position) + if isinstance(converted, dict): + params = _new_module.PreciseFlexArmBackend.DropParams(access=_to_new_access(access)) + await self._new_backend.drop_at_joint_position( + converted, resource_width=0, backend_params=params + ) + elif isinstance(converted, _new_module.PreciseFlexCartesianPose): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + await self._new_backend._place_plate_c(cartesian_position=converted, access=new_access) + else: + raise TypeError("Position must be of type Dict[int, float] or PreciseFlexCartesianPose.") + + async def move_to(self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]]): + converted = _to_new_coords(position) + if isinstance(converted, dict): + await self._new_backend.move_to_joint_position(converted) + elif isinstance(converted, _new_module.PreciseFlexCartesianPose): + await self._new_backend._move_c( + profile_index=self._new_backend.profile_index, cartesian_coords=converted + ) + else: + raise TypeError("Position must be of type Dict[int, float] or PreciseFlexCartesianPose.") + + async def get_joint_position(self) -> Dict[int, float]: + return await self._new_backend.request_joint_position() + + async def get_cartesian_position(self) -> PreciseFlexCartesianCoords: + result = await self._new_backend.request_gripper_location() + return _from_new_coords(result) + + async def send_command(self, command: str) -> str: + return await self._new_driver.send_command(command) + + def _parse_reply_ensure_successful(self, reply: bytes) -> str: + return self._new_driver._parse_reply_ensure_successful(reply) + + async def is_gripper_closed(self) -> bool: + return await self._new_backend.is_gripper_closed() + + async def are_grippers_closed(self) -> tuple[bool, bool]: + return await self._new_backend.are_grippers_closed() + + async def freedrive_mode(self, free_axes: List[int]) -> None: + await self._new_backend.start_freedrive_mode(free_axes) + + async def end_freedrive_mode(self) -> None: + await self._new_backend.stop_freedrive_mode() + + async def set_base( + self, x_offset: float, y_offset: float, z_offset: float, z_rotation: float + ) -> None: + await self._new_backend.set_base(x_offset, y_offset, z_offset, z_rotation) + + async def get_base(self) -> tuple[float, float, float, float]: + return await self._new_backend.request_base() + + async def exit(self) -> None: + await self._new_driver.exit() + + async def get_power_state(self) -> int: + return await self._new_driver.request_power_state() + + async def set_power(self, enable: bool, timeout: int = 0) -> None: + await self._new_driver.set_power(enable, timeout) + + async def get_mode(self): + return await self._new_driver.request_mode() + + async def set_response_mode(self, mode) -> None: + await self._new_driver.set_response_mode(mode) + + async def get_monitor_speed(self) -> int: + return await self._new_backend.request_monitor_speed() + + async def set_monitor_speed(self, speed_percent: int) -> None: + await self._new_backend.set_monitor_speed(speed_percent) + + async def nop(self) -> None: + await self._new_backend.nop() + + async def get_payload(self) -> int: + return await self._new_backend.request_payload() + + async def set_payload(self, payload_percent: int) -> None: + await self._new_backend.set_payload(payload_percent) + + async def set_parameter(self, data_id, value, unit_number=None, sub_unit=None, array_index=None): + await self._new_backend.set_parameter(data_id, value, unit_number, sub_unit, array_index) + + async def get_parameter(self, data_id, unit_number=None, sub_unit=None, array_index=None): + return await self._new_backend.request_parameter(data_id, unit_number, sub_unit, array_index) + + async def reset(self, robot_number: int) -> None: + await self._new_backend.reset(robot_number) + + async def get_selected_robot(self) -> int: + return await self._new_backend.request_selected_robot() + + async def select_robot(self, robot_number: int) -> None: + await self._new_backend.select_robot(robot_number) + + async def get_signal(self, signal_number: int) -> int: + return await self._new_backend.request_signal(signal_number) + + async def set_signal(self, signal_number: int, value: int) -> None: + await self._new_backend.set_signal(signal_number, value) + + async def get_system_state(self) -> int: + return await self._new_backend.request_system_state() + + async def get_tool_transformation_values(self): + return await self._new_backend.request_tool_transformation_values() + + async def set_tool_transformation_values(self, x, y, z, yaw, pitch, roll): + await self._new_backend.set_tool_transformation_values(x, y, z, yaw, pitch, roll) + + async def get_version(self) -> str: + return await self._new_backend.request_version() + + async def get_location_angles(self, location_index): + data = await self.send_command(f"locAngles {location_index}") + parts = data.split(" ") + type_code = int(parts[0]) + if type_code != 1: + raise _new_module.PreciseFlexError(-1, "Location is not of angles type.") + station_index = int(parts[1]) + angles = self._parse_angles_response(parts[2:]) + return (type_code, station_index, angles) + + async def set_joint_angles(self, location_index, joint_position): + await self._new_backend._set_joint_angles(location_index, joint_position) + + async def get_location_xyz(self, location_index): + data = await self.send_command(f"locXyz {location_index}") + parts = data.split(" ") + type_code = int(parts[0]) + if type_code != 0: + raise _new_module.PreciseFlexError(-1, "Location is not of Cartesian type.") + if len(parts) != 8: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from locXyz command.") + station_index = int(parts[1]) + x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[2:8]) + return (type_code, station_index, x, y, z, yaw, pitch, roll) + + async def set_location_xyz(self, location_index, cartesian_position): + converted = _to_new_coords(cartesian_position) + if not isinstance(converted, _new_module.PreciseFlexCartesianPose): + raise TypeError("Expected cartesian coordinates, got joint position dict") + await self._new_backend._set_location_xyz(location_index, converted) + + async def get_location_z_clearance(self, location_index): + data = await self.send_command(f"locZClearance {location_index}") + parts = data.split(" ") + if len(parts) != 3: + raise _new_module.PreciseFlexError( + -1, "Unexpected response format from locZClearance command." + ) + station_index = int(parts[0]) + z_clearance = float(parts[1]) + z_world = float(parts[2]) != 0 + return (station_index, z_clearance, z_world) + + async def set_location_z_clearance(self, location_index, z_clearance, z_world=None): + if z_world is None: + await self.send_command(f"locZClearance {location_index} {z_clearance}") + else: + z_world_int = 1 if z_world else 0 + await self.send_command(f"locZClearance {location_index} {z_clearance} {z_world_int}") + + async def get_location_config(self, location_index): + data = await self.send_command(f"locConfig {location_index}") + parts = data.split(" ") + if len(parts) != 2: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from locConfig command.") + return (int(parts[0]), int(parts[1])) + + async def set_location_config(self, location_index, config_value): + await self._new_backend._set_location_config(location_index, config_value) + + async def dest_c(self, arg1=0): + return await self._new_backend.dest_c(arg1) + + async def dest_j(self, arg1=0): + return await self._new_backend.dest_j(arg1) + + async def here_j(self, location_index): + await self._new_backend.here_j(location_index) + + async def here_c(self, location_index): + await self._new_backend.here_c(location_index) + + async def get_profile_speed(self, profile_index): + return await self._new_backend.request_profile_speed(profile_index) + + async def set_profile_speed(self, profile_index, speed_percent): + await self._new_backend.set_profile_speed(profile_index, speed_percent) + + async def get_profile_speed2(self, profile_index): + return await self._new_backend.request_profile_speed2(profile_index) + + async def set_profile_speed2(self, profile_index, speed2_percent): + await self._new_backend.set_profile_speed2(profile_index, speed2_percent) + + async def get_profile_accel(self, profile_index): + return await self._new_backend.request_profile_accel(profile_index) + + async def set_profile_accel(self, profile_index, accel_percent): + await self._new_backend.set_profile_accel(profile_index, accel_percent) + + async def get_profile_accel_ramp(self, profile_index): + return await self._new_backend.request_profile_accel_ramp(profile_index) + + async def set_profile_accel_ramp(self, profile_index, accel_ramp_seconds): + await self._new_backend.set_profile_accel_ramp(profile_index, accel_ramp_seconds) + + async def get_profile_decel(self, profile_index): + return await self._new_backend.request_profile_decel(profile_index) + + async def set_profile_decel(self, profile_index, decel_percent): + await self._new_backend.set_profile_decel(profile_index, decel_percent) + + async def get_profile_decel_ramp(self, profile_index): + return await self._new_backend.request_profile_decel_ramp(profile_index) + + async def set_profile_decel_ramp(self, profile_index, decel_ramp_seconds): + await self._new_backend.set_profile_decel_ramp(profile_index, decel_ramp_seconds) + + async def get_profile_in_range(self, profile_index): + return await self._new_backend.request_profile_in_range(profile_index) + + async def set_profile_in_range(self, profile_index, in_range_value): + await self._new_backend.set_profile_in_range(profile_index, in_range_value) + + async def get_profile_straight(self, profile_index): + return await self._new_backend.request_profile_straight(profile_index) + + async def set_profile_straight(self, profile_index, straight_mode): + await self._new_backend.set_profile_straight(profile_index, straight_mode) + + async def set_motion_profile_values( + self, + profile, + speed, + speed2, + acceleration, + deceleration, + acceleration_ramp, + deceleration_ramp, + in_range, + straight, + ): + await self._new_backend.set_motion_profile_values( + profile, + speed, + speed2, + acceleration, + deceleration, + acceleration_ramp, + deceleration_ramp, + in_range, + straight, + ) + + async def get_motion_profile_values(self, profile): + return await self._new_backend.request_motion_profile_values(profile) + + async def move_to_stored_location(self, location_index, profile_index): + await self._new_backend._move_to_stored_location(location_index, profile_index) + + async def move_to_stored_location_appro(self, location_index, profile_index): + await self._new_backend._move_to_stored_location_appro(location_index, profile_index) + + async def move_extra_axis(self, axis1_position, axis2_position=None): + if axis2_position is None: + await self.send_command(f"moveExtraAxis {axis1_position}") + else: + await self.send_command(f"moveExtraAxis {axis1_position} {axis2_position}") + + async def move_one_axis(self, axis_number, destination_position, profile_index): + await self.send_command(f"moveOneAxis {axis_number} {destination_position} {profile_index}") + + async def move_c(self, profile_index, cartesian_coords): + converted = _to_new_coords(cartesian_coords) + if not isinstance(converted, _new_module.PreciseFlexCartesianPose): + raise TypeError("Expected cartesian coordinates, got joint position dict") + await self._new_backend._move_c(profile_index, converted) + + async def move_j(self, profile_index, joint_coords): + await self._new_backend._move_j(profile_index, joint_coords) + + async def release_brake(self, axis): + await self._new_backend.release_brake(axis) + + async def set_brake(self, axis): + await self._new_backend.set_brake(axis) + + async def state(self): + return await self._new_driver.state() + + async def wait_for_eom(self): + await self._new_driver._wait_for_eom() + + async def zero_torque(self, enable, axis_mask=1): + await self._new_backend.zero_torque(enable, axis_mask) + + async def change_config(self, grip_mode=0): + await self._new_backend.change_config(grip_mode) + + async def change_config2(self, grip_mode=0): + await self._new_backend.change_config2(grip_mode) + + async def get_grasp_data(self): + return await self._new_backend._request_grasp_data() + + async def set_grasp_data(self, plate_width, finger_speed_percent, grasp_force): + await self._new_backend._set_grasp_data(plate_width, finger_speed_percent, grasp_force) + + async def _get_grip_close_pos(self): + return await self._new_backend._request_grip_close_pos() + + async def _set_grip_close_pos(self, close_position): + await self._new_backend._set_grip_close_pos(close_position) + + async def _get_grip_open_pos(self): + return await self._new_backend._request_grip_open_pos() + + async def _set_grip_open_pos(self, open_position): + await self._new_backend._set_grip_open_pos(open_position) + + async def move_rail(self, station_id=None, mode=0, rail_destination=None): + if rail_destination is not None: + await self.send_command(f"MoveRail {station_id or ''} {mode} {rail_destination}") + elif station_id is not None: + await self.send_command(f"MoveRail {station_id} {mode}") + else: + await self.send_command(f"MoveRail {mode}") + + async def get_pallet_index(self, station_id): + data = await self.send_command(f"PalletIndex {station_id}") + parts = data.split() + if len(parts) != 4: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletIndex command.") + return (int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])) + + async def set_pallet_index( + self, station_id, pallet_index_x=0, pallet_index_y=0, pallet_index_z=0 + ): + if pallet_index_x < 0: + raise ValueError("Pallet index X cannot be negative") + if pallet_index_y < 0: + raise ValueError("Pallet index Y cannot be negative") + if pallet_index_z < 0: + raise ValueError("Pallet index Z cannot be negative") + await self.send_command( + f"PalletIndex {station_id} {pallet_index_x} {pallet_index_y} {pallet_index_z}" + ) + + async def get_pallet_origin(self, station_id): + data = await self.send_command(f"PalletOrigin {station_id}") + parts = data.split() + if len(parts) != 8: + raise _new_module.PreciseFlexError( + -1, "Unexpected response format from PalletOrigin command." + ) + return ( + int(parts[0]), + float(parts[1]), + float(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + float(parts[6]), + int(parts[7]), + ) + + async def set_pallet_origin(self, station_id, cartesian_coords): + cmd = ( + f"PalletOrigin {station_id} " + f"{cartesian_coords.location.x} " + f"{cartesian_coords.location.y} " + f"{cartesian_coords.location.z} " + f"{cartesian_coords.rotation.yaw} " + f"{cartesian_coords.rotation.pitch} " + f"{cartesian_coords.rotation.roll} " + ) + if cartesian_coords.orientation is not None: + config_int = self._convert_orientation_enum_to_int(cartesian_coords.orientation) + cmd += f"{config_int}" + await self.send_command(cmd) + + async def get_pallet_x(self, station_id): + data = await self.send_command(f"PalletX {station_id}") + parts = data.split() + if len(parts) != 5: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletX command.") + return (int(parts[0]), int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])) + + async def set_pallet_x(self, station_id, x_position_count, world_x, world_y, world_z): + await self.send_command( + f"PalletX {station_id} {x_position_count} {world_x} {world_y} {world_z}" + ) + + async def get_pallet_y(self, station_id): + data = await self.send_command(f"PalletY {station_id}") + parts = data.split() + if len(parts) != 5: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletY command.") + return (int(parts[0]), int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])) + + async def set_pallet_y(self, station_id, y_position_count, world_x, world_y, world_z): + await self.send_command( + f"PalletY {station_id} {y_position_count} {world_x} {world_y} {world_z}" + ) + + async def get_pallet_z(self, station_id): + data = await self.send_command(f"PalletZ {station_id}") + parts = data.split() + if len(parts) != 5: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletZ command.") + return (int(parts[0]), int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])) + + async def set_pallet_z(self, station_id, z_position_count, world_x, world_y, world_z): + await self.send_command( + f"PalletZ {station_id} {z_position_count} {world_x} {world_y} {world_z}" + ) + + async def pick_plate_station( + self, station_id, horizontal_compliance=False, horizontal_compliance_torque=0 + ): + horizontal_compliance_int = 1 if horizontal_compliance else 0 + ret_code = await self.send_command( + f"PickPlate {station_id} {horizontal_compliance_int} {horizontal_compliance_torque}" + ) + return ret_code != "0" + + async def place_plate_station( + self, station_id, horizontal_compliance=False, horizontal_compliance_torque=0 + ): + horizontal_compliance_int = 1 if horizontal_compliance else 0 + await self.send_command( + f"PlacePlate {station_id} {horizontal_compliance_int} {horizontal_compliance_torque}" + ) + + async def get_rail_position(self, station_id): + data = await self.send_command(f"Rail {station_id}") + return float(data) + + async def set_rail_position(self, station_id, rail_position): + await self.send_command(f"Rail {station_id} {rail_position}") + + async def teach_plate_station(self, station_id, z_clearance=50.0): + await self.send_command(f"TeachPlate {station_id} {z_clearance}") + + async def get_station_type(self, station_id): + data = await self.send_command(f"StationType {station_id}") + parts = data.split() + if len(parts) != 6: + raise _new_module.PreciseFlexError(-1, "Unexpected response format from StationType command.") + return ( + int(parts[0]), + int(parts[1]), + int(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + ) + + async def set_station_type( + self, station_id, access_type, location_type, z_clearance, z_above, z_grasp_offset + ): + if access_type not in (0, 1): + raise ValueError("Access type must be 0 (horizontal) or 1 (vertical)") + if location_type not in (0, 1): + raise ValueError("Location type must be 0 (normal single) or 1 (pallet)") + await self.send_command( + f"StationType {station_id} {access_type} {location_type} {z_clearance} {z_above} {z_grasp_offset}" + ) + + async def home_all_if_no_plate(self): + response = await self.send_command("HomeAll_IfNoPlate") + return int(response) + + async def _grasp_plate(self, plate_width_mm, finger_speed_percent, grasp_force): + if not 1 <= finger_speed_percent <= 100: + raise ValueError("Finger speed percent must be between 1 and 100") + response = await self.send_command( + f"GraspPlate {plate_width_mm} {finger_speed_percent} {grasp_force}" + ) + return int(response) + + async def _release_plate(self, open_width_mm, finger_speed_percent, in_range=0.0): + if not 1 <= finger_speed_percent <= 100: + raise ValueError("Finger speed percent must be between 1 and 100") + await self.send_command(f"ReleasePlate {open_width_mm} {finger_speed_percent} {in_range}") + + async def set_active_gripper(self, gripper_id, spin_mode=0, profile_index=None): + if gripper_id not in (1, 2): + raise ValueError("Gripper ID must be 1 or 2") + if spin_mode not in (0, 1): + raise ValueError("Spin mode must be 0 or 1") + if profile_index is not None: + await self.send_command(f"SetActiveGripper {gripper_id} {spin_mode} {profile_index}") + else: + await self.send_command(f"SetActiveGripper {gripper_id} {spin_mode}") + + async def get_active_gripper(self): + response = await self.send_command("GetActiveGripper") + return int(response) + + async def pick_plate_from_stored_position( + self, position_id, horizontal_compliance=False, horizontal_compliance_torque=0 + ): + horizontal_compliance_int = 1 if horizontal_compliance else 0 + ret_code = await self.send_command( + f"pickplate {position_id} {horizontal_compliance_int} {horizontal_compliance_torque}" + ) + if ret_code == "0": + raise _new_module.PreciseFlexError( + -1, "the force-controlled gripper detected no plate present." + ) + + async def place_plate_to_stored_position( + self, position_id, horizontal_compliance=False, horizontal_compliance_torque=0 + ): + horizontal_compliance_int = 1 if horizontal_compliance else 0 + await self.send_command( + f"placeplate {position_id} {horizontal_compliance_int} {horizontal_compliance_torque}" + ) + + async def teach_position(self, position_id, z_clearance=50.0): + await self.send_command(f"teachplate {position_id} {z_clearance}") + + def _parse_xyz_response(self, parts): + return self._new_backend._parse_xyz_response(parts) + + def _parse_angles_response(self, parts): + return self._new_backend._parse_angles_response(parts) + + async def _approach_j(self, position: Dict[int, float], access=None): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._approach_j(position, new_access) + + async def _pick_plate_j(self, position: Dict[int, float], access=None): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._pick_plate_j(position, new_access) + + async def _place_plate_j(self, position: Dict[int, float], access=None): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._place_plate_j(position, new_access) + + async def _approach_c(self, position: PreciseFlexCartesianCoords, access=None): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._approach_c(_to_new_cartesian(position), new_access) + + async def _pick_plate_c(self, position: PreciseFlexCartesianCoords, access=None): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._pick_plate_c(_to_new_cartesian(position), new_access) + + async def _place_plate_c(self, position: PreciseFlexCartesianCoords, access=None): + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._place_plate_c(_to_new_cartesian(position), new_access) + + async def _set_grip_detail(self, access): + if access is not None and not isinstance(access, (VerticalAccess, HorizontalAccess)): + raise TypeError("Access pattern must be VerticalAccess or HorizontalAccess.") + new_access = _to_new_access(access) or _new_module.VerticalAccess() + return await self._new_backend._set_grip_detail(new_access) 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 98% 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..489246a4cb7 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 @@ -24,14 +27,22 @@ def setUp(self): self.mock_socket_instance.write.return_value = None self.mock_socket_instance.setup.return_value = None # Configure setup to return None self.mock_socket_instance._writer = AsyncMock() # Mock the _writer attribute + self.mock_socket_instance._host = "localhost" # Mock the _host attribute for logging + self.mock_socket_instance._port = 10100 # Mock the _port attribute for logging - # Patch the Socket class where it's used in PreciseFlexBackend - patcher = patch( - "pylabrobot.arms.precise_flex.precise_flex_backend.Socket", + # Patch the Socket class where it's used in PreciseFlexBackend and the new driver + patcher_legacy = patch( + "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 - self.addCleanup(patcher.stop) + patcher_new = patch( + "pylabrobot.brooks.precise_flex.Socket", + return_value=self.mock_socket_instance, + ) + self.MockSocketClass = patcher_legacy.start() + patcher_new.start() + self.addCleanup(patcher_legacy.stop) + self.addCleanup(patcher_new.stop) self.backend = PreciseFlexBackend(has_rail=False, host="localhost", port=10100) # self.backend.io is already self.mock_socket_instance because of the patch @@ -251,7 +262,7 @@ async def test_approach_cartesian_space(self): async def test_approach_invalid_position_type(self): with self.assertRaisesRegex( - TypeError, r"Position must be of type Dict\[int, float\] or CartesianCoords." + TypeError, r"Position must be of type Dict\[int, float\] or PreciseFlexCartesianPose." ): await self.backend.approach("invalid") # type: ignore @@ -282,7 +293,7 @@ async def test_pick_plate_invalid_position_type(self): b"0 OK\r\n", # For set_grasp_data ] with self.assertRaisesRegex( - TypeError, r"Position must be of type Dict\[int, float\] or CartesianCoords." + TypeError, r"Position must be of type Dict\[int, float\] or PreciseFlexCartesianPose." ): await self.backend.pick_up_resource("invalid", plate_width=1.0) # type: ignore @@ -306,7 +317,7 @@ async def test_place_plate(self): async def test_place_plate_invalid_position_type(self): with self.assertRaisesRegex( - TypeError, "place_plate only supports CartesianCoords for PreciseFlex." + TypeError, r"Position must be of type Dict\[int, float\] or PreciseFlexCartesianPose." ): await self.backend.drop_resource("invalid") # type: ignore @@ -338,7 +349,7 @@ async def test_move_to_cartesian_space(self): async def test_move_to_invalid_position_type(self): with self.assertRaisesRegex( - TypeError, r"Position must be of type Dict\[int, float\] or CartesianCoords." + TypeError, r"Position must be of type Dict\[int, float\] or PreciseFlexCartesianPose." ): await self.backend.move_to("invalid") # type: ignore 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/legacy/arms/standard.py b/pylabrobot/legacy/arms/standard.py new file mode 100644 index 00000000000..a3fb8c4f3a4 --- /dev/null +++ b/pylabrobot/legacy/arms/standard.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from pylabrobot.resources import Coordinate, Rotation + + +@dataclass +class CartesianCoords: + location: Coordinate + rotation: Rotation diff --git a/pylabrobot/legacy/barcode_scanners/__init__.py b/pylabrobot/legacy/barcode_scanners/__init__.py new file mode 100644 index 00000000000..92e0b620988 --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/__init__.py @@ -0,0 +1,6 @@ +"""Legacy. Use pylabrobot.capabilities.barcode_scanning instead.""" + +from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError +from pylabrobot.legacy.barcode_scanners.keyence import KeyenceBarcodeScannerBackend + +from .barcode_scanner import BarcodeScanner diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/legacy/barcode_scanners/backend.py similarity index 74% rename from pylabrobot/barcode_scanners/backend.py rename to pylabrobot/legacy/barcode_scanners/backend.py index 4a8b75fb9ae..957b0ddd3b7 100644 --- a/pylabrobot/barcode_scanners/backend.py +++ b/pylabrobot/legacy/barcode_scanners/backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.barcode_scanning.backend instead.""" + 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 54% rename from pylabrobot/barcode_scanners/barcode_scanner.py rename to pylabrobot/legacy/barcode_scanners/barcode_scanner.py index 821e5789ae2..045c74246dc 100644 --- a/pylabrobot/barcode_scanners/barcode_scanner.py +++ b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py @@ -1,10 +1,15 @@ -from pylabrobot.barcode_scanners.backend import BarcodeScannerBackend -from pylabrobot.machines.machine import Machine +"""Legacy. Use pylabrobot.capabilities.barcode_scanning instead.""" + +from pylabrobot.legacy.barcode_scanners.backend import BarcodeScannerBackend +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources.barcode import Barcode class BarcodeScanner(Machine): - """Frontend for barcode scanners.""" + """Legacy standalone barcode scanner Machine. + + In new code, use BarcodeScanner instead. + """ def __init__(self, backend: BarcodeScannerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/legacy/barcode_scanners/keyence/__init__.py b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py new file mode 100644 index 00000000000..bf35d3e64a1 --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py @@ -0,0 +1,5 @@ +"""Legacy. Use pylabrobot.keyence instead.""" + +from pylabrobot.legacy.barcode_scanners.keyence.keyence_backend import ( + KeyenceBarcodeScannerBackend, # noqa: F401 +) diff --git a/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py new file mode 100644 index 00000000000..43c9f5b8edc --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py @@ -0,0 +1,31 @@ +"""Legacy. Use pylabrobot.keyence instead.""" + +from pylabrobot.keyence.keyence_backend import ( + KeyenceBarcodeScannerBarcodeScanningBackend, + KeyenceBarcodeScannerDriver, +) +from pylabrobot.legacy.barcode_scanners.backend import BarcodeScannerBackend +from pylabrobot.resources.barcode import Barcode + + +class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): + """Legacy wrapper around the new Driver + CapabilityBackend. + + In new code, use KeyenceBarcodeScanner (Device) instead. + """ + + def __init__(self, port: str): + super().__init__() + self.driver = KeyenceBarcodeScannerDriver(port=port) + self._barcode_scanning = KeyenceBarcodeScannerBarcodeScanningBackend(self.driver) + + async def setup(self): + await self.driver.setup() + await self._barcode_scanning._on_setup() + + async def stop(self): + await self._barcode_scanning._on_stop() + await self.driver.stop() + + async def scan_barcode(self) -> Barcode: + return await self._barcode_scanning.scan_barcode() diff --git a/pylabrobot/legacy/centrifuge/__init__.py b/pylabrobot/legacy/centrifuge/__init__.py new file mode 100644 index 00000000000..df6a67cc292 --- /dev/null +++ b/pylabrobot/legacy/centrifuge/__init__.py @@ -0,0 +1,10 @@ +from .access2 import Access2 +from .centrifuge import Centrifuge, Loader +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 84% rename from pylabrobot/centrifuge/centrifuge.py rename to pylabrobot/legacy/centrifuge/centrifuge.py index 2476882e5ad..7bda675bcaa 100644 --- a/pylabrobot/centrifuge/centrifuge.py +++ b/pylabrobot/legacy/centrifuge/centrifuge.py @@ -1,15 +1,15 @@ import warnings from typing import Optional, Tuple -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 @@ -134,18 +134,22 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): - backend = CentrifugeBackend.deserialize(data["backend"]) - buckets = tuple(ResourceHolder.deserialize(bucket) for bucket in data["buckets"]) - assert len(buckets) == 2 + buckets_data = data.get("buckets") + buckets = ( + tuple(ResourceHolder.deserialize(bucket) for bucket in buckets_data) if buckets_data else None + ) + if buckets is not None: + assert len(buckets) == 2 + rotation_data = data.get("rotation") return cls( - backend=backend, + backend=CentrifugeBackend.deserialize(data["backend"]), name=data["name"], 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=deserialize(rotation_data) if rotation_data else None, + category=data.get("category"), + model=data.get("model"), buckets=buckets, ) @@ -228,15 +232,18 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): + resource_data = data.get("resource", {}) + machine_data = data.get("machine", {}) + rotation_data = resource_data.get("rotation") return cls( - backend=LoaderBackend.deserialize(data["machine"]["backend"]), + backend=LoaderBackend.deserialize(machine_data["backend"]), centrifuge=Centrifuge.deserialize(data["centrifuge"]), - name=data["resource"]["name"], - size_x=data["resource"]["size_x"], - 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"], + name=resource_data["name"], + size_x=resource_data["size_x"], + size_y=resource_data["size_y"], + size_z=resource_data["size_z"], + child_location=deserialize(resource_data["child_location"]), + rotation=deserialize(rotation_data) if rotation_data else None, + category=resource_data.get("category"), + model=resource_data.get("model"), ) diff --git a/pylabrobot/centrifuge/centrifuge_tests.py b/pylabrobot/legacy/centrifuge/centrifuge_tests.py similarity index 93% rename from pylabrobot/centrifuge/centrifuge_tests.py rename to pylabrobot/legacy/centrifuge/centrifuge_tests.py index 9dbf6c56d8b..2fc3d27a0d6 100644 --- a/pylabrobot/centrifuge/centrifuge_tests.py +++ b/pylabrobot/legacy/centrifuge/centrifuge_tests.py @@ -1,6 +1,7 @@ import unittest +import unittest.mock -from pylabrobot.centrifuge import ( +from pylabrobot.legacy.centrifuge import ( BucketHasPlateError, BucketNoPlateError, Centrifuge, @@ -9,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 @@ -136,7 +140,7 @@ async def test_unload_not_at_bucket(self): self.mock_loader_backend.unload.assert_not_awaited() def test_serialize(self): - self.loader.backend = LoaderChatterboxBackend() - self.centrifuge.backend = CentrifugeChatterboxBackend() + self.loader._backend = LoaderChatterboxBackend() + self.centrifuge._backend = CentrifugeChatterboxBackend() serialized = self.loader.serialize() self.assertEqual(Loader.deserialize(serialized), self.loader) 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/legacy/centrifuge/standard.py b/pylabrobot/legacy/centrifuge/standard.py new file mode 100644 index 00000000000..eb546eb0f79 --- /dev/null +++ b/pylabrobot/legacy/centrifuge/standard.py @@ -0,0 +1,9 @@ +"""Legacy. Use pylabrobot.capabilities.centrifuging.errors instead.""" + +from pylabrobot.capabilities.centrifuging.errors import ( # noqa: F401 + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) diff --git a/pylabrobot/legacy/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py new file mode 100644 index 00000000000..ebea3846dd2 --- /dev/null +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -0,0 +1,188 @@ +"""Legacy. Use pylabrobot.agilent.vspin instead.""" + +import logging +from typing import Optional + +from pylabrobot.agilent.vspin import vspin as _new +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.standard import LoaderNoPlateError + +logger = logging.getLogger(__name__) + + +class Access2Backend(LoaderBackend): + """Legacy. Use pylabrobot.agilent.vspin.Access2Driver instead.""" + + def __init__(self, device_id: str, timeout: int = 60): + self.driver = _new.Access2Driver(device_id=device_id, timeout=timeout) + + @property + def io(self): + return self.driver.io + + @io.setter + def io(self, value): + self.driver.io = value + + @property + def timeout(self): + return self.driver.timeout + + @timeout.setter + def timeout(self, value): + self.driver.timeout = value + + async def setup(self): + await self.driver.setup() + + async def stop(self): + await self.driver.stop() + + def serialize(self): + return {"io": self.io.serialize(), "timeout": self.timeout} + + async def send_command(self, command: bytes) -> bytes: + return await self.driver.send_command(command) + + async def get_status(self) -> bytes: + return await self.driver.request_status() + + async def park(self): + await self.driver.park() + + async def close(self): + await self.driver.close() + + async def open(self): + await self.driver.open() + + async def load(self): + try: + await self.driver.load() + except RuntimeError as e: + if "no plate found on stage" in str(e): + raise LoaderNoPlateError("no plate found on stage") from e + raise + + async def unload(self): + try: + await self.driver.unload() + except RuntimeError as e: + if "no plate found in centrifuge" in str(e): + raise LoaderNoPlateError("no plate found in centrifuge") from e + raise + + +class VSpinBackend(CentrifugeBackend): + """Legacy. Use pylabrobot.agilent.vspin.VSpinDriver instead.""" + + def __init__(self, device_id: Optional[str] = None): + self.driver = _new.VSpinDriver(device_id=device_id) + self._centrifuge = _new.VSpinCentrifugeBackend(self.driver) + + @property + def io(self): + return self.driver.io + + @io.setter + def io(self, value): + self.driver.io = value + + @property + def _bucket_1_remainder(self): + return self._centrifuge._bucket_1_remainder + + @_bucket_1_remainder.setter + def _bucket_1_remainder(self, value): + self._centrifuge._bucket_1_remainder = value + + @property + def bucket_1_remainder(self) -> int: + return self._centrifuge.bucket_1_remainder + + async def setup(self): + await self.driver.setup() + await self._centrifuge._on_setup() + + async def stop(self): + await self._centrifuge._on_stop() + await self.driver.stop() + + async def set_bucket_1_position_to_current(self) -> None: + await self._centrifuge.set_bucket_1_position_to_current() + + async def get_bucket_1_position(self) -> int: + return await self._centrifuge.request_bucket_1_position() + + async def get_position(self) -> int: + return await self.driver.request_position() + + async def get_tachometer(self) -> int: + return await self.driver.request_tachometer() + + async def get_home_position(self) -> int: + return await self.driver.request_home_position() + + async def get_bucket_locked(self) -> bool: + return await self.driver.request_bucket_locked() + + async def get_door_open(self) -> bool: + return await self.driver.request_door_open() + + async def get_door_locked(self) -> bool: + return await self.driver.request_door_locked() + + async def open_door(self): + await self._centrifuge.open_door() + + async def close_door(self): + await self._centrifuge.close_door() + + async def lock_door(self): + await self._centrifuge.lock_door() + + async def unlock_door(self): + await self._centrifuge.unlock_door() + + async def lock_bucket(self): + await self._centrifuge.lock_bucket() + + async def unlock_bucket(self): + await self._centrifuge.unlock_bucket() + + async def go_to_bucket1(self): + await self._centrifuge.go_to_bucket1() + + async def go_to_bucket2(self): + await self._centrifuge.go_to_bucket2() + + async def go_to_position(self, position: int): + await self._centrifuge.go_to_position(position) + + @staticmethod + def g_to_rpm(g: float) -> int: + return _new.VSpinCentrifugeBackend.g_to_rpm(g) + + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + await self._centrifuge.spin( + g=g, + duration=duration, + backend_params=_new.VSpinCentrifugeBackend.SpinParams( + acceleration=acceleration, deceleration=deceleration + ), + ) + + async def configure_and_initialize(self): + await self.driver.configure_and_initialize() + + +# Deprecated alias +class VSpin: + def __init__(self, *args, **kwargs): + raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") diff --git a/pylabrobot/legacy/heating_shaking/__init__.py b/pylabrobot/legacy/heating_shaking/__init__.py new file mode 100644 index 00000000000..cfd14c7360c --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/__init__.py @@ -0,0 +1,14 @@ +"""A hybrid between pylabrobot.shaking and pylabrobot.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.hamilton.heater_shaker.box import HamiltonHeaterShakerBox +from pylabrobot.legacy.heating_shaking.hamilton_backend import HamiltonHeaterShakerBackend +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/legacy/heating_shaking/bioshake_backend.py b/pylabrobot/legacy/heating_shaking/bioshake_backend.py new file mode 100644 index 00000000000..d641d7f358d --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/bioshake_backend.py @@ -0,0 +1,64 @@ +"""Legacy. Use pylabrobot.qinstruments.BioShakeDriver instead.""" + +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.qinstruments.bioshake import ( + BioShakeDriver, + BioShakeShakerBackend, + BioShakeTemperatureBackend, +) + + +class BioShake(HeaterShakerBackend): + """Legacy. Use pylabrobot.qinstruments.BioShakeDriver instead.""" + + def __init__(self, port: str, timeout: int = 60): + self.driver = BioShakeDriver(port=port, timeout=timeout) + self._shaker = BioShakeShakerBackend(self.driver) + self._temp = BioShakeTemperatureBackend(self.driver) + + @property + def supports_active_cooling(self) -> bool: + return self._temp.supports_active_cooling + + @property + def supports_locking(self) -> bool: + return self._shaker.supports_locking + + async def setup(self, skip_home: bool = False): + await self.driver.setup(backend_params=BioShakeDriver.SetupParams(skip_home=skip_home)) + + async def stop(self): + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def reset(self): + await self.driver.reset() + + async def home(self): + await self.driver.home() + + async def start_shaking(self, speed: float, acceleration: int = 0): + await self._shaker.start_shaking(speed=speed, acceleration=acceleration) + + async def shake(self, speed: float, acceleration: int = 0): + await self._shaker.start_shaking(speed=speed, acceleration=acceleration) + + async def stop_shaking(self, deceleration: int = 0): + await self._shaker.stop_shaking(deceleration=deceleration) + + async def lock_plate(self): + await self._shaker.lock_plate() + + async def unlock_plate(self): + await self._shaker.unlock_plate() + + async def set_temperature(self, temperature: float): + await self._temp.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._temp.request_current_temperature() + + async def deactivate(self): + await self._temp.deactivate() 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/legacy/heating_shaking/hamilton_backend.py b/pylabrobot/legacy/heating_shaking/hamilton_backend.py new file mode 100644 index 00000000000..63139ac50c1 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/hamilton_backend.py @@ -0,0 +1,87 @@ +"""Legacy. Use pylabrobot.hamilton.heater_shaker instead.""" + +import warnings +from typing import Dict, Literal, Optional + +from pylabrobot.hamilton.heater_shaker.backend import HamiltonHeaterShakerBackend as _NewBackend +from pylabrobot.hamilton.usb.driver import HamiltonUSBDriver +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend + + +class HamiltonHeaterShakerBackend(HeaterShakerBackend): + """Legacy. Use pylabrobot.hamilton.heater_shaker instead.""" + + def __init__(self, index: int, interface: HamiltonUSBDriver) -> None: + self._backend = _NewBackend(driver=interface, index=index) + + @property + def supports_active_cooling(self) -> bool: + return self._backend.supports_active_cooling + + @property + def supports_locking(self) -> bool: + return self._backend.supports_locking + + async def setup(self): + await self._backend._on_setup() + + async def stop(self): + await self._backend._on_stop() + + def serialize(self) -> dict: + warnings.warn("The interface is not serialized.") + return {"index": self._backend.index, "interface": None} + + async def start_shaking( + self, + speed: float = 800, + direction: Literal[0, 1] = 0, + acceleration: int = 1_000, + timeout: Optional[float] = 30, + ): + await self._backend.start_shaking( + speed=speed, direction=direction, acceleration=acceleration, timeout=timeout + ) + + async def shake( + self, + speed: float = 800, + direction: Literal[0, 1] = 0, + acceleration: int = 1_000, + timeout: Optional[float] = 30, + ): + warnings.warn( + "HamiltonHeaterShakerBackend.shake() is deprecated. Use start_shaking() instead.", + DeprecationWarning, + stacklevel=2, + ) + await self.start_shaking( + speed=speed, direction=direction, acceleration=acceleration, timeout=timeout + ) + + async def stop_shaking(self): + await self._backend.stop_shaking() + + async def get_is_shaking(self) -> bool: + return await self._backend.request_is_shaking() + + async def lock_plate(self): + await self._backend.lock_plate() + + async def unlock_plate(self): + await self._backend.unlock_plate() + + async def set_temperature(self, temperature: float): + await self._backend.set_temperature(temperature=temperature) + + async def get_current_temperature(self) -> float: + return await self._backend.request_current_temperature() + + async def _get_current_temperature(self) -> Dict[str, float]: + return await self._backend._request_current_temperature() + + async def get_edge_temperature(self) -> float: + return await self._backend.request_edge_temperature() + + async def deactivate(self): + await self._backend.deactivate() 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/legacy/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py new file mode 100644 index 00000000000..1bea4c3eef3 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py @@ -0,0 +1,78 @@ +"""Legacy. Use pylabrobot.inheco.thermoshake.InhecoThermoshakeBackend instead.""" + +from pylabrobot.inheco import thermoshake +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend + + +class InhecoThermoshakeBackend(HeaterShakerBackend): + """Legacy. Use pylabrobot.inheco.InhecoThermoshakeBackend instead.""" + + def __init__(self, index: int, control_box): + self._new = thermoshake.InhecoThermoshakeBackend(index=index, control_box=control_box) + + @property + def index(self) -> int: + return self._new.index + + @property + def interface(self): + return self._new.interface + + @property + def supports_active_cooling(self) -> bool: + return self._new.supports_active_cooling + + @property + def supports_locking(self) -> bool: + return self._new.supports_locking + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._new.request_current_temperature() + + async def deactivate(self): + await self._new.deactivate() + + async def set_target_temperature(self, temperature: float): + await self._new.set_target_temperature(temperature) + + async def start_temperature_control(self): + return await self._new.start_temperature_control() + + async def stop_temperature_control(self): + return await self._new.stop_temperature_control() + + async def get_device_info(self, info_type: int): + return await self._new.request_device_info(info_type) + + async def start_shaking(self, speed: float, shape: int = 0): + await self._new.start_shaking(speed=speed, shape=shape) + + async def stop_shaking(self): + return await self._new.stop_shaking() + + async def set_shaker_speed(self, speed: float): + return await self._new.set_shaker_speed(speed) + + async def set_shaker_shape(self, shape: int): + return await self._new.set_shaker_shape(shape) + + async def shake(self, speed: float, shape: int = 0): + await self._new.start_shaking(speed=speed, shape=shape) + + async def lock_plate(self): + await self._new.lock_plate() + + async def unlock_plate(self): + await self._new.unlock_plate() 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..50c01b189ad --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/__init__.py @@ -0,0 +1,9 @@ +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_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 02e8642d51b..da0517a0649 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..6c0be7d8b92 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py @@ -0,0 +1,8225 @@ +import asyncio +import datetime +import enum +import functools +import logging +import re +import sys +import warnings +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass, field +from typing import ( + Any, + Awaitable, + Callable, + Coroutine, + Dict, + List, + Literal, + Optional, + Sequence, + Tuple, + TypedDict, + TypeVar, + Union, + cast, +) + +if sys.version_info < (3, 10): + from typing_extensions import Concatenate, ParamSpec +else: + from typing import Concatenate, ParamSpec + +from typing import TYPE_CHECKING + +from pylabrobot import audio +from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend + +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload + from pylabrobot.hamilton.liquid_handlers.star.cover import STARCover + from pylabrobot.hamilton.liquid_handlers.star.head96_backend import STARHead96Backend + from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend + from pylabrobot.hamilton.liquid_handlers.star.wash_station import STARWashStation + from pylabrobot.hamilton.liquid_handlers.star.x_arm import STARXArm +from pylabrobot.hamilton.liquid_handlers.star.errors import ( + CommandSyntaxError, # noqa: F401 (re-exported for STAR_tests) + HamiltonNoTipError, # noqa: F401 (re-exported for STAR_tests) + HardwareError, # noqa: F401 (re-exported for STAR_tests) + STARFirmwareError, + UnknownHamiltonError, # noqa: F401 (re-exported for STAR_tests) + convert_star_firmware_error_to_plr_error, + star_firmware_string_to_error, +) +from pylabrobot.hamilton.liquid_handlers.star.fw_parsing import parse_star_fw_string +from pylabrobot.hamilton.liquid_handlers.star.pip_channel import ( + PressureLLDMode as _NewPressureLLDMode, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import ( + HamiltonLiquidHandler, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.planning import group_by_x_batch_by_xy +from pylabrobot.legacy.liquid_handling.channel_positioning import ( + MIN_SPACING_EDGE, + get_wide_single_resource_liquid_op_offsets, +) +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) +from pylabrobot.legacy.liquid_handling.standard import ( + Drop, + DropTipRack, + GripDirection, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + PipettingOp, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import ( + Carrier, + Container, + Coordinate, + Plate, + Resource, + Tip, + TipRack, + Well, +) +from pylabrobot.resources.barcode import Barcode, Barcode1DSymbology +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.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): + await self.driver.ensure_iswap_parked() + 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 _convert_immersion_depth( + immersion_depth: Optional[List[float]], + immersion_depth_direction: Optional[List[int]], +) -> Optional[List[float]]: + """Convert legacy (unsigned depth + direction flag) to new (signed depth). + + New API: positive = go deeper, negative = go up. + Legacy: immersion_depth is unsigned, direction 0 = deeper, 1 = up. + """ + if immersion_depth is None: + return None + if immersion_depth_direction is None: + return immersion_depth # already correct sign convention + return [ + d * (-1 if direction == 1 else 1) + for d, direction in zip(immersion_depth, immersion_depth_direction) + ] + + +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 for an X drive (left or right). + + Combines byte 1 (xl/xr) and byte 2 (xn/xo) into a single object. + 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 + + +@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: DriveConfiguration = field(default_factory=DriveConfiguration) + """Right X drive configuration (xr + xo).""" + 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 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 + supports_clot_monitoring_clld: bool + stop_disc_type: StopDiscType + instrument_type: InstrumentType + head_type: HeadType + + +class STARBackend(HamiltonLiquidHandler): + """Interface for the Hamilton STARBackend.""" + + 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, + ) + + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + + # Deck arrives via set_deck() (legacy flow), so construct the driver without one + # and attach it in set_deck(). STARDriver.setup() asserts deck is set. + self.driver = STARDriver( + deck=None, # type: ignore[arg-type] + device_address=device_address, + serial_number=serial_number, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + left_side_panel_installed=left_side_panel_installed, + ) + + self.left_side_panel_installed = left_side_panel_installed + self._machine_conf: Optional[MachineConfiguration] = 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.core_adjustment = Coordinate.zero() + self._unsafe = UnSafe(self) + + self._iswap_version: Optional[str] = None # loaded lazily + + self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" + + self._setup_done = False + + @property + def left_x_arm(self): + return self.driver.left_x_arm + + @property + def iswap(self): + return self.driver.iswap + + @property + def _pip(self) -> STARPIPBackend: + """Typed access to the STAR PIP backend.""" + return self.driver.pip # type: ignore[return-value] + + @property + def _iswap(self) -> "iSWAPBackend": + """Typed access to the iSWAP backend (asserts not None).""" + assert self.driver.iswap is not None, "iSWAP is not installed" + return self.driver.iswap + + @property + def _left_x_arm(self) -> "STARXArm": + """Typed access to the left X arm (asserts not None).""" + assert self.driver.left_x_arm is not None, "Left X arm is not available" + return self.driver.left_x_arm + + @property + def _autoload(self) -> "STARAutoload": + """Typed access to the autoload subsystem (asserts not None).""" + assert self.driver.autoload is not None, "Autoload is not installed" + return self.driver.autoload + + @property + def _wash_station(self) -> "STARWashStation": + """Typed access to the wash station (asserts not None).""" + assert self.driver.wash_station is not None, "Wash station is not installed" + return self.driver.wash_station + + @property + def _star_head96(self) -> "STARHead96Backend": + """Typed access to the Head96 backend (asserts not None).""" + assert self.driver.head96 is not None, "96-head is not installed" + return self.driver.head96 # type: ignore[return-value] + + @property + def _cover(self) -> "STARCover": + """Typed access to the cover (asserts not None).""" + assert self.driver.cover is not None, "Cover is not available" + return self.driver.cover + + @property + def _write_and_read_command(self): + return self.driver._write_and_read_command + + @_write_and_read_command.setter + def _write_and_read_command(self, value): + self.driver._write_and_read_command = value # type: ignore[method-assign] + + def _min_spacing_between(self, i: int, j: int) -> float: + """Return the firmware-safe minimum Y spacing between channels *i* and *j*. + + Uses max() of both channels' spacings for firmware safety (conservative). + For adjacent channels, ceiling-rounded to 0.1mm. + For non-adjacent channels, the sum of all intermediate adjacent-pair spacings. + """ + 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) + 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._pip.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): + """Deprecated: use ``self._iswap.use_traversal_height()``.""" + with self._iswap.use_traversal_height(traversal_height): + yield + + @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: + if self.driver.iswap is not None: + return self._iswap.parked + return False + + @property + def core_parked(self) -> bool: + return self._core_parked is True + + async def get_iswap_version(self) -> str: + """Lazily load the iSWAP version. Use cached value if available.""" + if self._iswap_version is None: + self._iswap_version = await self.request_iswap_version() + return self._iswap_version + + async def request_pip_channel_version(self, channel: int) -> str: + """Deprecated: use ``star.pip.backend.channels[n].request_firmware_version()``.""" + pip_channel = self._pip_channels[channel] + resp = await pip_channel.send_command( + module=pip_channel.module_id, + command="RF", + fmt="rf" + "&" * 17, + ) + return str(resp["rf"]) + + 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) + + def set_deck(self, deck): + super().set_deck(deck) + self.driver.deck = deck # type: ignore[assignment] + + 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. + """ + + # Let the driver own the USB connection and query machine config. + await self.driver.setup() + + # Sync legacy state from driver. + self.id_ = 0 + self._machine_conf = self.driver.machine_conf # type: ignore[assignment] + self._extended_conf = self.driver.extended_conf # type: ignore[assignment] + 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: + await self.move_core_96_to_safe_position() + + tip_presences = await self.request_tip_presence() + self._num_channels = len(tip_presences) + + async def set_up_pip(): + if (not initialized or any(tip_presences)) and not skip_pip: + await self.initialize_pip() + self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() + + 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) + ) + + 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._pip.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() + + self._head96_information = Head96Information( + fw_version=fw_version, + supports_clot_monitoring_clld=bool(int(configuration_96head[0])), + stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", + instrument_type="legacy" if configuration_96head[2] == "0" else "FM-STAR", + head_type=head96_type, + ) + + 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._pip_channels = self._pip.channels + + self._setup_done = True + + async def send_command( + self, + module, + command, + auto_id=True, + tip_pattern=None, + write_timeout=None, + read_timeout=None, + wait=True, + fmt=None, + **kwargs, + ): + return await self.driver.send_command( + module=module, + command=command, + auto_id=auto_id, + tip_pattern=tip_pattern, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + fmt=fmt, + **kwargs, + ) + + async def stop(self): + await self.driver.stop() + self._setup_done = False + + @property + def setup_done(self) -> bool: + return self._setup_done + + # ============== LiquidHandlerBackend methods ============== + + # # # # 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 the minimum Y spacing for all channels in parallel. + + Each channel is addressed on its own module (P1, P2, ...), so the queries + can run concurrently. + + Returns: + A list of exact (unrounded) minimum Y spacings in mm, one per channel, + indexed by channel number. + """ + return list( + await asyncio.gather( + *( + self.channel_request_y_minimum_spacing(channel_idx=idx) + for idx 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: + """Deprecated: use ``star.pip.backend.channels[n].request_cycle_counts()``.""" + return await self._pip_channels[channel_idx].request_cycle_counts() # type: ignore[return-value] + + 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, + ): + """Deprecated: use ``star.pip.backend.pick_up_tips()``.""" + from pylabrobot.capabilities.liquid_handling.standard import Pickup as NewPickup + + PickUpTipsParams = self._pip.PickUpTipsParams + + new_ops = [NewPickup(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + params = PickUpTipsParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command + or self._pip.traversal_height, + pickup_method=pickup_method, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + ) + return await self._pip.pick_up_tips(new_ops, use_channels, backend_params=params) + + 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, + ): + """Deprecated: use ``star.pip.backend.drop_tips()``.""" + from pylabrobot.capabilities.liquid_handling.standard import TipDrop as NewTipDrop + + DropTipsParams = self._pip.DropTipsParams + + new_ops = [NewTipDrop(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] # type: ignore[arg-type] + params = DropTipsParams( + drop_method=drop_method, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command + or self._pip.traversal_height, + z_position_at_end_of_a_command=z_position_at_end_of_a_command or self._pip.traversal_height, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + ) + return await self._pip.drop_tips(new_ops, use_channels, backend_params=params) + + 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 _move_to_traverse_height( + self, channels: Optional[List[int]] = None, traverse_height: Optional[float] = None + ): + """Move channels to a specified traverse height, if given, otherwise move to full Z safety. + + Args: + channels: Channels to move. If None, all channels are moved. + traverse_height: Absolute Z position in mm. If None, move to full Z safety. + """ + if traverse_height is None: + await self.move_all_channels_in_z_safety() + else: + if channels is None: + channels = list(range(self.num_channels)) + await self.position_channels_in_z_direction( + {channel: traverse_height for channel in channels} + ) + + async def _probe_liquid_heights_batch( + self, + containers: List[Container], + use_channels: List[int], + lld_mode: LLDMode = LLDMode.GAMMA, + search_speed: float = 10.0, + n_replicates: int = 1, + ) -> List[float]: + """Helper for probe_liquid_heights that performs a single batch of liquid level detection using a set of channels. + + Assumes channels are moved to the appropriate traverse height before calling, and does not move channels after completion. + """ + + tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] + + detect_func: Callable[..., Any] + if lld_mode == self.LLDMode.GAMMA: + detect_func = self._move_z_drive_to_liquid_surface_using_clld + else: + detect_func = self._search_for_surface_using_plld + + # Compute Z search bounds for this batch + batch_lowest_immers = [ + container.get_absolute_location("c", "c", "cavity_bottom").z + + tip_len + - self.DEFAULT_TIP_FITTING_DEPTH + for container, tip_len in zip(containers, tip_lengths) + ] + batch_start_pos = [ + container.get_absolute_location("c", "c", "t").z + + tip_len + - self.DEFAULT_TIP_FITTING_DEPTH + + 5 + for container, tip_len in zip(containers, tip_lengths) + ] + + absolute_heights_measurements: Dict[int, List[Optional[float]]] = { + idx: [] for idx in range(len(use_channels)) + } + + # Run n_replicates detection loop for this batch + for _ in range(n_replicates): + errors = await asyncio.gather( + *[ + detect_func( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + ) + for channel, lip, sps in zip(use_channels, batch_lowest_immers, batch_start_pos) + ], + return_exceptions=True, + ) + + # Get heights for ALL channels, handling failures for channels with no liquid + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + for idx, (channel_idx, error) in enumerate(zip(use_channels, errors)): + if isinstance(error, STARFirmwareError): + error_msg = str(error).lower() + if "no liquid level found" in error_msg or "no liquid was present" in error_msg: + height = None + msg = ( + f"Operation {idx} (channel {channel_idx}): No liquid detected. Could be because there is " + f"no liquid in container {containers[idx].name} or liquid level " + f"is too low." + ) + if lld_mode == self.LLDMode.GAMMA: + msg += " Consider using pressure-based LLD if liquid is believed to exist." + logger.warning(msg) + else: + raise error + elif isinstance(error, Exception): + raise error + else: + height = current_absolute_liquid_heights[channel_idx] + absolute_heights_measurements[idx].append(height) + + # Compute liquid heights relative to well bottom + relative_to_well: List[float] = [] + inconsistent_ops: List[str] = [] + + for idx, container in enumerate(containers): + measurements = absolute_heights_measurements[idx] + valid = [m for m in measurements if m is not None] + cavity_bottom = container.get_absolute_location("c", "c", "cavity_bottom").z + + 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_ops.append( + f"Operation {idx}: {len(valid)}/{len(measurements)} replicates detected liquid" + ) + + if inconsistent_ops: + raise RuntimeError( + "Inconsistent liquid detection across replicates. " + "This may indicate liquid levels near the detection limit:\n" + "\n".join(inconsistent_ops) + ) + + return relative_to_well + + def _get_maximum_minimum_spacing_between_channels(self, use_channels: List[int]) -> float: + """Get the maximum of the set of minimum spacing requirements between the channels being used""" + sorted_channels = sorted(use_channels) + max_channel_spacing = max( + self._min_spacing_between(hi, lo) for hi, lo in zip(sorted_channels[1:], sorted_channels[:-1]) + ) + return max_channel_spacing + + def _compute_channels_in_resource_locations( + self, + resources: Sequence[Resource], + use_channels: List[int], + offsets: Optional[List[Coordinate]], + ) -> List[Coordinate]: + """Compute absolute locations of resources with given offsets.""" + + # If no offset is provided but we can fit all channels inside a single resource, + # compute the offsets to make that happen using wide spacing. + if offsets is None: + if len(set(resources)) == 1 and len(use_channels) == len(set(use_channels)): + container_size_y = resources[0].get_absolute_size_y() + # For non-consecutive channels (e.g. [0,1,2,5,6,7]), we must account for + # phantom intermediate channels (3,4) that physically exist between them. + # Compute offsets for the full channel range (min to max), then pick only + # the offsets corresponding to the actual channels being used. + max_channel_spacing = self._get_maximum_minimum_spacing_between_channels(use_channels) + num_channels_in_span = max(use_channels) - min(use_channels) + 1 + min_required = MIN_SPACING_EDGE * 2 + (num_channels_in_span - 1) * max_channel_spacing + if container_size_y >= min_required: + all_offsets = get_wide_single_resource_liquid_op_offsets( + resource=resources[0], + num_channels=num_channels_in_span, + min_spacing=max_channel_spacing, + ) + min_ch = min(use_channels) + offsets = [all_offsets[ch - min_ch] for ch in use_channels] + # else: container too small to fit all channels — fall back to center offsets. + # Y sub-batching will serialize channels that can't coexist. + + offsets = offsets or [Coordinate.zero()] * len(resources) + + # Compute positions for all resources + resource_locations = [ + resource.get_location_wrt(self.deck, x="c", y="c", z="b") + offset + for resource, offset in zip(resources, offsets) + ] + + return resource_locations + + async def execute_batched( # TODO: any hamilton liquid handler + self, + func: Callable[[List[int]], Awaitable[None]], + resources: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + min_traverse_height_during_command: Optional[float] = None, + ): + if use_channels is None: + use_channels = list(range(len(resources))) + + # precompute locations and batches + locations = self._compute_channels_in_resource_locations( + resources, use_channels, resource_offsets + ) + x_batches = group_by_x_batch_by_xy( + locations=locations, + use_channels=use_channels, + min_spacing_between_channels=self._min_spacing_between, + ) + + # loop over batches. keep track of channels used in previous batch to ensure they are raised to traverse height before next batch + prev_channels: Optional[List[int]] = None + + try: + for x_value, x_batch in x_batches.items(): + if prev_channels is not None: + await self._move_to_traverse_height( + channels=prev_channels, traverse_height=min_traverse_height_during_command + ) + await self.move_channel_x(0, x_value) + + for y_batch in x_batch: + if prev_channels is not None: + await self._move_to_traverse_height( + channels=prev_channels, traverse_height=min_traverse_height_during_command + ) + await self.position_channels_in_y_direction( + {use_channels[idx]: locations[idx].y for idx in y_batch}, + ) + + await func(y_batch) + + prev_channels = [use_channels[idx] for idx in y_batch] + except Exception: + await self.move_all_channels_in_z_safety() + raise + except BaseException: + await self.move_all_channels_in_z_safety() + raise + + async def probe_liquid_heights( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: LLDMode = LLDMode.GAMMA, + 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, + # 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. + + 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. Auto-calculated for single + containers with odd channel counts to avoid center dividers. Defaults to container centers. + lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. + Defaults to capacitive. + 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. None (default) uses full Z safety. + min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to + between batches (X groups and Y sub-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. + + Returns: + Mean of measured liquid heights for each container (mm from cavity bottom). + + Raises: + RuntimeError: If channels lack tips. + + Notes: + - All specified channels must have tips attached + - Containers at different X positions are probed in sequential groups (single X carriage) + - For single containers with no-go zones, Y-offsets are computed to avoid + obstructed regions (e.g. center dividers in troughs) + """ + + 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, + ) + + # Validate parameters. + if use_channels is None: + use_channels = list(range(len(containers))) + if len(use_channels) == 0: + raise ValueError("use_channels must not be empty.") + if not all(0 <= ch < self.num_channels for ch in use_channels): + raise ValueError( + f"All use_channels must be integers in range [0, {self.num_channels - 1}], " + f"got {use_channels}." + ) + + if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: + raise ValueError(f"LLDMode must be 1 (capacitive) or 2 (pressure-based), is {lld_mode}") + + if not len(containers) == len(use_channels): + raise ValueError( + "Length of containers and use_channels must match, " + f"got lengths {len(containers)}, {len(use_channels)}." + ) + + # Validate resource_offsets length (if provided) to avoid silent truncation in downstream zips. + if resource_offsets is not None and len(resource_offsets) != len(containers): + raise ValueError( + "Length of resource_offsets must match the length of containers and use_channels, " + f"got lengths {len(resource_offsets)} (resource_offsets) and " + f"{len(containers)} (containers/use_channels)." + ) + # Make sure we have tips on all channels and know their 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.") + + # Move channels to traverse height + await self._move_to_traverse_height( + channels=use_channels, traverse_height=min_traverse_height_at_beginning_of_command + ) + + result_by_operation: Dict[int, float] = {} + + async def func(batch: List[int]): + liquid_heights = await self._probe_liquid_heights_batch( + containers=[containers[idx] for idx in batch], + use_channels=[use_channels[idx] for idx in batch], + lld_mode=lld_mode, + search_speed=search_speed, + n_replicates=n_replicates, + ) + for idx, height in zip(batch, liquid_heights): + result_by_operation[idx] = height + + await self.execute_batched( + func=func, + resources=containers, + use_channels=use_channels, + resource_offsets=resource_offsets, + min_traverse_height_during_command=min_traverse_height_during_command, + ) + + await self._move_to_traverse_height( + channels=use_channels, + traverse_height=z_position_at_end_of_command, + ) + + return [result_by_operation[idx] for idx in range(len(containers))] + + async def probe_liquid_volumes( + self, + containers: List[Container], + use_channels: List[int], + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: LLDMode = LLDMode.GAMMA, + search_speed: float = 10.0, + n_replicates: int = 3, + move_to_z_safety_after: bool = True, + ) -> 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). + 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 - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. Defaults to capacitive. + search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. + n_replicates: Number of measurements per channel. Default 3. + move_to_z_safety_after: Whether to move channels to safe Z height after probing. Default True. + + 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 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, + move_to_z_safety_after=move_to_z_safety_after, + ) + + 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: + """Deprecated: use ``star.pip.backend.channels[n].request_dispensing_drive_position()``.""" + return await self._pip_channels[channel_idx].request_dispensing_drive_position() + + 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, + ): + """Deprecated: use ``star.pip.backend.channels[n].move_dispensing_drive_to_position()``.""" + return await self._pip_channels[channel_idx].move_dispensing_drive_to_position( + vol=vol, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=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, + ): + """Deprecated: use ``star.pip.backend.channels[n].empty_tip()``.""" + return await self._pip_channels[channel_idx].empty_tip( + vol=vol, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + reset_dispensing_drive_after=reset_dispensing_drive_after, + ) + + 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, + ): + """Deprecated: use ``star.pip.backend.aspirate()``.""" + + from pylabrobot.capabilities.liquid_handling.standard import Aspiration as NewAspiration + + AspirateParams = self._pip.AspirateParams + from pylabrobot.hamilton.liquid_handlers.star.pip_backend import LLDMode as NewLLDMode + + # # # 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 + if ratio_liquid_rise_to_tip_deep_in is not None: + warnings.warn( + "ratio_liquid_rise_to_tip_deep_in is deprecated.", DeprecationWarning, stacklevel=2 + ) + if immersion_depth_2nd_section is not None: + warnings.warn("immersion_depth_2nd_section is deprecated.", DeprecationWarning, stacklevel=2) + # # # delete # # # + + # Convert lld_mode enums from legacy to new + new_lld_mode = None + if lld_mode is not None: + new_lld_mode = [NewLLDMode(m.value) for m in lld_mode] + + new_ops = [ + NewAspiration( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=op.mix, # type: ignore[arg-type] + ) + for op in ops + ] + + params = AspirateParams( + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + jet=jet, + blow_out=blow_out, + lld_search_height=lld_search_height, + clot_detection_height=clot_detection_height, + pull_out_distance_transport_air=pull_out_distance_transport_air, + second_section_height=second_section_height, + second_section_ratio=second_section_ratio, + minimum_height=minimum_height, + immersion_depth=_convert_immersion_depth(immersion_depth, immersion_depth_direction), + surface_following_distance=surface_following_distance, + transport_air_volume=transport_air_volume, + pre_wetting_volume=pre_wetting_volume, + lld_mode=new_lld_mode, + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + aspirate_position_above_z_touch_off=aspirate_position_above_z_touch_off, + detection_height_difference_for_dual_lld=detection_height_difference_for_dual_lld, + swap_speed=swap_speed, + settling_time=settling_time, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + mix_surface_following_distance=mix_surface_following_distance, + limit_curve_index=limit_curve_index, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command + or self._pip.traversal_height, + min_z_endpos=min_z_endpos or self._pip.traversal_height, + liquid_surface_no_lld=liquid_surface_no_lld, + use_2nd_section_aspiration=use_2nd_section_aspiration, + retract_height_over_2nd_section_to_empty_tip=retract_height_over_2nd_section_to_empty_tip, + dispensation_speed_during_emptying_tip=dispensation_speed_during_emptying_tip, + dosing_drive_speed_during_2nd_section_search=dosing_drive_speed_during_2nd_section_search, + z_drive_speed_during_2nd_section_search=z_drive_speed_during_2nd_section_search, + cup_upper_edge=cup_upper_edge, + probe_liquid_height=probe_liquid_height, + auto_surface_following_distance=auto_surface_following_distance, + ) + + return await self._pip.aspirate(new_ops, use_channels, backend_params=params) + + 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, + ): + """Deprecated: use ``star.pip.backend.dispense()``.""" + + from pylabrobot.capabilities.liquid_handling.standard import Dispense as NewDispense + + DispenseParams = self._pip.DispenseParams + from pylabrobot.hamilton.liquid_handlers.star.pip_backend import LLDMode as NewLLDMode + + # # # 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.", + DeprecationWarning, + ) + # # # delete # # # + + new_lld_mode = None + if lld_mode is not None: + new_lld_mode = [NewLLDMode(m.value) for m in lld_mode] + + new_ops = [ + NewDispense( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=op.mix, # type: ignore[arg-type] + ) + for op in ops + ] + + params = DispenseParams( + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + jet=jet, + blow_out=blow_out, + empty=empty, + lld_search_height=lld_search_height, + liquid_surface_no_lld=liquid_surface_no_lld, + pull_out_distance_transport_air=pull_out_distance_transport_air, + second_section_height=second_section_height, + second_section_ratio=second_section_ratio, + minimum_height=minimum_height, + immersion_depth=_convert_immersion_depth(immersion_depth, immersion_depth_direction), + surface_following_distance=surface_following_distance, + cut_off_speed=cut_off_speed, + stop_back_volume=stop_back_volume, + transport_air_volume=transport_air_volume, + lld_mode=new_lld_mode, + side_touch_off_distance=side_touch_off_distance, + dispense_position_above_z_touch_off=dispense_position_above_z_touch_off, + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + swap_speed=swap_speed, + settling_time=settling_time, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + mix_surface_following_distance=mix_surface_following_distance, + limit_curve_index=limit_curve_index, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command + or self._pip.traversal_height, + min_z_endpos=min_z_endpos or self._pip.traversal_height, + probe_liquid_height=probe_liquid_height, + auto_surface_following_distance=auto_surface_following_distance, + ) + + return await self._pip.dispense(new_ops, use_channels, backend_params=params) + + @_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._pip.traversal_height) * 10 + ), + minimum_height_command_end=round( + (minimum_height_command_end or self._pip.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._pip.traversal_height) * 10 + ), + minimum_height_command_end=round( + (minimum_height_command_end or self._pip.traversal_height) * 10 + ), + ) + + @_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 + 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._pip.traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._pip.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, + ) + + @_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) + + 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._pip.traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._pip.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), + ) + + 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): + """Deprecated: use ``star.pip.backend.prepare_for_manual_channel_operation()``.""" + await self.driver.pip.prepare_for_manual_channel_operation(channel) + + async def move_channel_x(self, channel: int, x: float): + """Deprecated: use ``star.driver.left_x_arm.move_to()``.""" + await self._left_x_arm.move_to(x) + + @need_iswap_parked + async def move_channel_y(self, channel: int, y: float): + """Deprecated: use ``star.driver.pip.channels[n].move_y()``.""" + await self.driver.pip.channels[channel].move_y(y) + + async def move_channel_z(self, channel: int, z: float): + """Deprecated: use ``channels[n].move_stop_disk_z()`` or ``channels[n].move_tool_z()``.""" + await self._pip.channels[channel].move_stop_disk_z(z) + + 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, + ): + """Deprecated: use ``star.pip.backend.channels[n].move_stop_disk_z()``.""" + return await self._pip.channels[channel_idx].move_stop_disk_z( + z, speed, acceleration, current_limit + ) + + async def move_channel_tool_z(self, channel_idx: int, z: float): + """Deprecated: use ``star.pip.backend.channels[n].move_tool_z()``.""" + return await self._pip.channels[channel_idx].move_tool_z(z) + + 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): + """Deprecated: use ``star.driver.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): + """Deprecated: use ``star.driver.request_error_code()``.""" + + return await self.send_command(module="C0", command="RE") + + async def request_firmware_version(self): + """Deprecated: use ``star.driver.request_firmware_version()``.""" + + return await self.send_command(module="C0", command="RF") + + async def request_parameter_value(self): + """Deprecated: use ``star.driver.request_parameter_value()``.""" + + 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): + """Deprecated: use ``star.driver.request_electronic_board_type()``.""" + + resp = await self.send_command(module="C0", command="QB") + try: + return STARBackend.BoardType(resp["qb"]) + except ValueError: + return STARBackend.BoardType.UNKNOWN + + async def request_supply_voltage(self): + """Deprecated: use ``star.driver.request_supply_voltage()``.""" + + return await self.send_command(module="C0", command="MU") + + async def request_instrument_initialization_status(self) -> bool: + """Deprecated: use ``star.driver.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: + """Deprecated: use ``star.autoload.request_initialization_status()``.""" + return await self._autoload.request_initialization_status() + + async def request_name_of_last_faulty_parameter(self): + """Deprecated: use ``star.driver.request_name_of_last_faulty_parameter()``.""" + + return await self.send_command(module="C0", command="VP", fmt="vp&&") + + async def request_master_status(self): + """Deprecated: use ``star.driver.request_master_status()``.""" + + return await self.send_command(module="C0", command="RQ") + + async def request_number_of_presence_sensors_installed(self): + """Deprecated: use ``star.driver.request_number_of_presence_sensors_installed()``.""" + + resp = await self.send_command(module="C0", command="SR") + return resp["sr"] + + async def request_eeprom_data_correctness(self): + """Deprecated: use ``star.driver.request_eeprom_data_correctness()``.""" + + 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): + """Deprecated: use ``star.driver.set_single_step_mode()``.""" + + return await self.send_command( + module="C0", + command="AM", + am=single_step_mode, + ) + + async def trigger_next_step(self): + """Deprecated: use ``star.driver.trigger_next_step()``.""" + + # TODO: this command has no reply!!!! + return await self.send_command(module="C0", command="NS") + + async def halt(self): + """Deprecated: use ``star.driver.halt()``.""" + + return await self.send_command(module="C0", command="HD") + + async def save_all_cycle_counters(self): + """Deprecated: use ``star.driver.save_all_cycle_counters()``.""" + + return await self.send_command(module="C0", command="AZ") + + async def set_not_stop(self, non_stop): + """Deprecated: use ``star.driver.set_not_stop()``.""" + + 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", + ): + """Deprecated: use ``star.driver.store_installation_data()``.""" + + 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, + ): + """Deprecated: use ``star.driver.store_verification_data()``.""" + + 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): + """Deprecated: use ``star.driver.additional_time_stamp()``.""" + + return await self.send_command(module="C0", command="AT") + + async def set_x_offset_x_axis_iswap(self, x_offset: int): + """Deprecated: use ``star.driver.set_x_offset_x_axis_iswap()``.""" + + 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): + """Deprecated: use ``star.driver.set_x_offset_x_axis_core_96_head()``.""" + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): + """Deprecated: use ``star.driver.set_x_offset_x_axis_core_nano_pipettor_head()``.""" + + 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()): + """Deprecated: use ``star.driver.save_download_date()``.""" + + 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): + """Deprecated: use ``star.driver.save_technical_status_of_assemblies()``.""" + + 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, + ): + """Deprecated: use ``star.driver.set_instrument_configuration()``.""" + + 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): + """Deprecated: use ``star.driver.save_pip_channel_validation_status()``.""" + + return await self.send_command( + module="C0", + command="AJ", + tq=validation_status, + ) + + async def save_xl_channel_validation_status(self, validation_status: bool = False): + """Deprecated: use ``star.driver.save_xl_channel_validation_status()``.""" + + return await self.send_command( + module="C0", + command="AE", + tx=validation_status, + ) + + # TODO: response + async def configure_node_names(self): + """Deprecated: use ``star.driver.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"): + """Deprecated: use ``star.driver.set_deck_data()``.""" + + 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): + """Deprecated: use ``star.driver.request_technical_status_of_assemblies()``.""" + + # TODO: parse res + return await self.send_command(module="C0", command="QT") + + async def request_installation_data(self): + """Deprecated: use ``star.driver.request_installation_data()``.""" + + # TODO: parse res + return await self.send_command(module="C0", command="RI") + + async def request_device_serial_number(self) -> str: + """Deprecated: use ``star.driver.request_device_serial_number()``.""" + return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore + + async def request_download_date(self): + """Deprecated: use ``star.driver.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): + """Deprecated: use ``star.driver.request_verification_data()``.""" + + 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): + """Deprecated: use ``star.driver.request_additional_timestamp_data()``.""" + + # TODO: parse res + return await self.send_command(module="C0", command="RS") + + async def request_pip_channel_validation_status(self): + """Deprecated: use ``star.driver.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): + """Deprecated: use ``star.driver.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). + + Returns the full instrument configuration matching the AK + (Set Instrument Configuration) [SFCO.0026] parameter set. + """ + + 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####", + ) + + def _parse_drive(byte1: int, byte2: int) -> DriveConfiguration: + 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)), + ) + + 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=_parse_drive(resp["xl"], resp["xn"]), + right_x_drive=_parse_drive(resp["xr"], resp["xo"]), + 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): + """Deprecated: use ``star.driver.request_node_names()``.""" + + # TODO: parse res + return await self.send_command(module="C0", command="RK") + + async def request_deck_data(self): + """Deprecated: use ``star.driver.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): + """Deprecated: use ``star.left_x_arm.move_to()``.""" + return await self._left_x_arm.move_to(x_position=x_position / 10) + + async def position_right_x_arm_(self, x_position: int = 0): + """Deprecated: use ``star.right_x_arm.move_to()``.""" + assert self.driver.right_x_arm is not None, "Right X arm is not installed" + return await self.driver.right_x_arm.move_to(x_position=x_position / 10) + + async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position( + self, x_position: int = 0 + ): + """Deprecated: use ``star.left_x_arm.move_to_safe()``.""" + return await self._left_x_arm.move_to_safe(x_position=x_position / 10) + + async def move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position( + self, x_position: int = 0 + ): + """Deprecated: use ``star.right_x_arm.move_to_safe()``.""" + assert self.driver.right_x_arm is not None, "Right X arm is not installed" + return await self.driver.right_x_arm.move_to_safe(x_position=x_position / 10) + + # -------------- 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, + ): + """Deprecated: use ``star.driver.occupy_and_provide_area_for_external_access()``.""" + + 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): + """Deprecated: use ``star.driver.release_occupied_area()``.""" + + 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): + """Deprecated: use ``star.driver.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: + """Deprecated: use ``star.left_x_arm.request_position()``.""" + return await self._left_x_arm.request_position() + + async def request_right_x_arm_position(self) -> float: + """Deprecated: use ``star.right_x_arm.request_position()``.""" + assert self.driver.right_x_arm is not None, "Right X arm is not installed" + return await self.driver.right_x_arm.request_position() + + async def request_maximal_ranges_of_x_drives(self): + """Deprecated: use ``star.driver.request_maximal_ranges_of_x_drives()``.""" + + return await self.send_command(module="C0", command="RU") + + async def request_present_wrap_size_of_installed_arms(self): + """Deprecated: use ``star.driver.request_present_wrap_size_of_installed_arms()``.""" + + return await self.send_command(module="C0", command="UA") + + async def request_left_x_arm_last_collision_type(self): + """Deprecated: use ``star.left_x_arm.last_collision_type()``.""" + return await self._left_x_arm.last_collision_type() + + async def request_right_x_arm_last_collision_type(self) -> bool: + """Deprecated: use ``star.right_x_arm.last_collision_type()``.""" + assert self.driver.right_x_arm is not None, "Right X arm is not installed" + return await self.driver.right_x_arm.last_collision_type() + + # -------------- 3.5 Pipetting channel commands -------------- + + # -------------- 3.5.1 Initialization -------------- + + async def initialize_pip(self): + """Deprecated: use ``star.pip.backend.initialize_pip()``.""" + 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._pip.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, + ): + """Deprecated: use ``star.pip.backend.initialize_pipetting_channels()``.""" + + 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 + + command_output = await self.driver.pick_up_core_gripper_tools( + x_position=xs, + back_channel_y=back_channel_y_center, + front_channel_y=front_channel_y_center, + back_channel=back_channel, + front_channel=front_channel, + begin_z=235.0 + self.core_adjustment.z + z_offset, + end_z=225.0 + self.core_adjustment.z + z_offset, + traversal_height=self._iswap.traversal_height, + ) + 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 + + command_output = await self.driver.return_core_gripper_tools( + x_position=xs, + back_channel_y=back_channel_y_center, + front_channel_y=front_channel_y_center, + begin_z=215.0 + self.core_adjustment.z + z_offset, + end_z=205.0 + self.core_adjustment.z + z_offset, + traversal_height=self._iswap.traversal_height, + ) + 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 + ): + """Deprecated: use ``star.pip.backend.position_channels_in_y_direction()``.""" + return await self.driver.pip.position_channels_in_y_direction( + ys={pipetting_channel_index - 1: y_position / 10} + ) + + 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 + ): + """Deprecated: use ``star.driver.left_x_arm.clld_probe_x_position()``.""" + if self.driver.left_x_arm is None: + raise RuntimeError("left_x_arm not configured") + return await self.driver.left_x_arm.clld_probe_x_position( + channel_idx=pipetting_channel_index - 1, + probing_direction="right", + end_pos_search=x_position / 10, + ) + + async def spread_pip_channels(self): + """Deprecated: use ``star.pip.backend.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, + ): + """Deprecated: use ``star.pip.backend.move_all_pipetting_channels_to_defined_position()``.""" + + 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): + """Deprecated: use ``star.pip.backend.position_max_free_y_for_n()``.""" + + 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): + """Deprecated: use ``star.pip.backend.move_all_channels_in_z_safety()``.""" + + return await self.send_command(module="C0", command="ZA") + + # -------------- 3.5.7 PIP query -------------- + + # TODO:(command:RY): Request Y-Positions of all pipetting channels + + async def request_x_pos_channel_n(self, pipetting_channel_index: int = 0) -> float: + """Deprecated: use ``star.pip.channels[n].request_x_pos()``.""" + return await self.driver.pip.channels[pipetting_channel_index].request_x_pos() + + async def request_y_pos_channel_n(self, pipetting_channel_index: int) -> float: + """Deprecated: use ``star.pip.channels[n].request_y_pos()``.""" + return await self.driver.pip.channels[pipetting_channel_index].request_y_pos() + + # TODO:(command:RZ): Request Z-Positions of all pipetting 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: + """Deprecated: use ``star.pip.channels[n].request_tip_bottom_z_position()``.""" + return await self.driver.pip.channels[channel_idx].request_tip_bottom_z_position() + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Deprecated: use ``star.pip.backend.request_tip_presence()``.""" + return await self.driver.pip.request_tip_presence() + + 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]: + """Deprecated: use ``star.pip.backend.request_pip_height_last_lld()``.""" + return await self.driver.pip.request_pip_height_last_lld() + + async def request_tadm_status(self): + """Deprecated: use ``star.pip.channels[n].request_tadm_enabled()``.""" + return {i: await ch.request_tadm_enabled() for i, ch in enumerate(self.driver.pip.channels)} + + # 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).""" + return await self._star_head96.request_firmware_version() + + async def _head96_request_configuration(self) -> List[str]: + """Request the 96-head configuration (raw) using the QU command. + + The instrument returns a sequence of positional tokens. This method returns + those tokens without decoding them, but the following indices are currently + understood: + + - 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: reservable positions (positions 4..10) + + 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") + + # -------------- 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._star_head96.initialize( + x=loc.x, + y=loc.y, + z=loc.z, + minimum_height_command_end=z_position_at_the_command_end, + ) + + async def request_core_96_head_initialization_status(self) -> bool: + return await self._star_head96.request_initialization_status() + + 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. + """ + return await self._star_head96.initialize_dispensing_drive_and_squeezer( + squeezer_speed=squeezer_speed, + squeezer_acceleration=squeezer_acceleration, + squeezer_current_limit=squeezer_current_limit, + dispensing_drive_current_limit=dispensing_drive_current_limit, + ) + + # -------------- 3.10.2 96-Head Movements -------------- + + # Conversion factors for 96-Head (mm per increment) + _head96_z_drive_mm_per_increment = 0.005 + _head96_y_drive_mm_per_increment = 0.015625 + _head96_dispensing_drive_mm_per_increment = 0.001025641026 + _head96_dispensing_drive_uL_per_increment = 0.019340933 + _head96_squeezer_drive_mm_per_increment = 0.0002086672009 + + # 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) + + # 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) + + # 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) + + # 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): + """Move 96-Head to Z safety coordinate, i.e. z=342.5 mm.""" + return await self._star_head96.move_to_z_safety() + + @_requires_head96 + async def head96_park( + self, + ): + """Park the 96-head. + + Uses firmware default speeds and accelerations. + """ + return await self._star_head96.park() + + @_requires_head96 + async def head96_move_x(self, x: float): + """Move the 96-head to a specified X-axis coordinate. + + Note: Unlike head96_move_y and head96_move_z, the X-axis movement does not have + dedicated speed/acceleration parameters - it uses the EM command which moves + all axes together. + + Args: + x: Target X coordinate in mm. Valid range: [-271.0, 974.0] + + Returns: + Response from the hardware command. + + Raises: + RuntimeError: If 96-head is not installed. + """ + current_pos = await self.head96_request_position() + return await self.head96_move_to_coordinate( + Coordinate(x, current_pos.y, current_pos.z), + minimum_height_at_beginning_of_a_command=current_pos.z - 10, + ) + + @_requires_head96 + async def head96_move_y( + self, + y: float, + speed: float = 300.0, + acceleration: float = 300.0, + current_protection_limiter: int = 15, + ): + """Move the 96-head to a specified Y-axis coordinate. + + Args: + y: Target Y coordinate in mm. Valid range: [93.75, 562.5] + speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]. Default: 300.0 + acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]. Default: 300.0 + 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: + Maximum speed varies by firmware version: + - Pre-2021: 390.625 mm/sec (25,000 increments) + - 2021+: 625.0 mm/sec (40,000 increments) + The exact firmware version introducing this change is undocumented. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + + fw_version = self._head96_information.fw_version + + # Determine speed limit based on firmware version + # Pre-2021 firmware appears to have lower speed capability or safety limits + # TODO: Verify exact firmware version and investigate the reason for this change + y_speed_upper_limit = 390.625 if fw_version.year <= 2021 else 625.0 # mm/sec + + # Validate parameters before hardware communication + assert 93.75 <= y <= 562.5, "y must be between 93.75 and 562.5 mm" + assert 0.78125 <= speed <= y_speed_upper_limit, ( + f"speed must be between 0.78125 and {y_speed_upper_limit} mm/sec for firmware version {fw_version}. " + f"Your firmware version: {self._head96_information.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 78.125 <= acceleration <= 781.25, ( + "acceleration must be between 78.125 and 781.25 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) + + resp = 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}", + ) + + return resp + + @_requires_head96 + async def head96_move_z( + self, + z: float, + speed: float = 80.0, + acceleration: float = 300.0, + current_protection_limiter: int = 15, + ): + """Move the 96-head to a specified Z-axis coordinate. + + Args: + z: Target Z coordinate in mm. Valid range: [180.5, 342.5] + speed: Movement speed in mm/sec. Valid range: [0.25, 100.0]. Default: 80.0 + acceleration: Movement acceleration in mm/sec^2. Valid range: [25.0, 500.0]. Default: 300.0 + 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: + 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" + ) + + fw_version = self._head96_information.fw_version + + # Validate parameters before hardware communication + assert 180.5 <= z <= 342.5, "z must be between 180.5 and 342.5 mm" + assert 0.25 <= speed <= 100.0, "speed must be between 0.25 and 100.0 mm/sec" + assert 25.0 <= acceleration <= 500.0, "acceleration must be between 25.0 and 500.0 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 + ) + + resp = 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}", + ) + + return resp + + # -------------- 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 -------------- + + # # # 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')}``. + """ + return await self._star_head96.dispensing_drive_move_to_home_volume() + + # # # "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", + 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", + 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) + + return await self._star_head96.move_to_coordinate( + coordinate=coordinate, + minimum_height_at_beginning_of_a_command=minimum_height_at_beginning_of_a_command, + ) + + 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 + """ + + await self._star_head96.dispensing_drive_move_to_position( + position=position, + speed=speed, + stop_speed=stop_speed, + acceleration=acceleration, + current_protection_limiter=current_protection_limiter, + ) + + 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_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 + """ + return await self._star_head96.request_tip_presence() + + 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 + """ + return await self._star_head96.request_position() + + 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._star_head96.request_tadm_status() + + 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._star_head96.request_tadm_error_status() + + async def head96_dispensing_drive_request_position_mm(self) -> float: + """Request 96 Head dispensing drive position in mm""" + return await self._star_head96.dispensing_drive_request_position_mm() + + async def head96_dispensing_drive_request_position_uL(self) -> float: + """Request 96 Head dispensing drive position in uL""" + return await self._star_head96.dispensing_drive_request_position_uL() + + # -------------- 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): + """Deprecated: use ``star.autoload._on_setup()``.""" + return await self._autoload._on_setup() + + 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): + """Deprecated: use ``star.autoload.move_to_safe_z_position()``.""" + return await self._autoload.move_to_safe_z_position() + + 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: + """Deprecated: use ``star.autoload.request_track()``.""" + return await self._autoload.request_track() + + async def request_autoload_type(self) -> str: + """Deprecated: use ``star.autoload.request_type()``.""" + return await self._autoload.request_type() + + # -------------- 3.13.2 Carrier sensing -------------- + + def _decode_hex_bitmask_to_track_list(self, mask_hex: str) -> list[int]: + """Deprecated: use ``STARAutoload._decode_hex_bitmask_to_track_list()``.""" + from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload + + return STARAutoload._decode_hex_bitmask_to_track_list(mask_hex) + + async def request_presence_of_carriers_on_deck(self) -> list[int]: + """Deprecated: use ``star.autoload.request_presence_of_carriers_on_deck()``.""" + return await self._autoload.request_presence_of_carriers_on_deck() + + async def request_presence_of_carriers_on_loading_tray(self) -> list[int]: + """Deprecated: use ``star.autoload.request_presence_of_carriers_on_loading_tray()``.""" + return await self._autoload.request_presence_of_carriers_on_loading_tray() + + async def request_presence_of_single_carrier_on_loading_tray(self, track: int) -> bool: + """Deprecated: use ``star.autoload.request_presence_of_single_carrier_on_loading_tray()``.""" + return await self._autoload.request_presence_of_single_carrier_on_loading_tray(track) + + 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): + """Deprecated: use ``star.autoload.move_to_track()``.""" + return await self._autoload.move_to_track(track) + + async def park_autoload(self): + """Deprecated: use ``star.autoload.park()``.""" + return await self._autoload.park() + + async def take_carrier_out_to_autoload_belt(self, carrier: Carrier): + """Deprecated: use ``star.autoload.take_carrier_out_to_belt()``.""" + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + return await self._autoload.take_carrier_out_to_belt(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: + """Deprecated: use ``star.autoload.set_1d_barcode_type()``.""" + await self._autoload.set_1d_barcode_type(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]: + """Deprecated: use ``star.autoload.load_carrier_from_tray_and_scan_carrier_barcode()``.""" + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + return await self._autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=carrier_end_rail, + carrier_barcode_reading=carrier_barcode_reading, + barcode_symbology=barcode_symbology, + barcode_position=barcode_position, + barcode_reading_window_width=barcode_reading_window_width, + reading_speed=reading_speed, + ) + + async def unload_carrier_after_carrier_barcode_scanning(self): + """Deprecated: use ``star.autoload.unload_carrier_after_barcode_scanning()``.""" + return await self._autoload.unload_carrier_after_barcode_scanning() + + async def set_carrier_monitoring(self, should_monitor: bool = False): + """Deprecated: use ``star.autoload.set_carrier_monitoring()``.""" + return await self._autoload.set_carrier_monitoring(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]]: + """Deprecated: use ``star.autoload.load_carrier_from_belt()``.""" + return await self._autoload.load_carrier_from_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=park_autoload_after, + ) + + # -------------- 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: + """Deprecated: use ``star.autoload.load_carrier()``.""" + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + return await self._autoload.load_carrier( + carrier_end_rail=carrier_end_rail, + carrier_barcode_reading=carrier_barcode_reading, + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + barcode_symbology=barcode_symbology, + no_container_per_carrier=no_container_per_carrier, + reading_position_of_first_barcode=reading_position_of_first_barcode, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_autoload_after=park_autoload_after, + ) + + async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): + """Deprecated: use ``star.autoload.set_loading_indicators()``.""" + return await self._autoload.set_loading_indicators(bit_pattern, blink_pattern) + + async def verify_and_wait_for_carriers( + self, + check_interval: float = 1.0, + ): + """Deprecated: use ``star.autoload.verify_and_wait_for_carriers()``.""" + # Compute carrier rails from deck children (geometry stays in legacy). + carrier_rails: List[Tuple[int, int]] = [] + + for child in self.deck.children: + if isinstance(child, Carrier): + 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()) + 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)) + + return await self._autoload.verify_and_wait_for_carriers( + carrier_rails=carrier_rails, + check_interval=check_interval, + ) + + async def unload_carrier( + self, + carrier: Carrier, + park_autoload_after: bool = True, + ): + """Deprecated: use ``star.autoload.unload_carrier()``.""" + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + return await self._autoload.unload_carrier( + carrier_end_rail=carrier_end_rail, + park_autoload_after=park_autoload_after, + ) + + # -------------- 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): + """Deprecated: use ``star.wash_station.request_settings()``.""" + # Legacy returned the raw send_command dict; preserve that contract. + 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): + """Deprecated: use ``star.wash_station.initialize_valves()``.""" + return await self._wash_station.initialize_valves(station=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, + ): + """Deprecated: use ``star.wash_station.fill_chamber()``.""" + return await self._wash_station.fill_chamber( + station=pump_station, + drain_before_refill=drain_before_refill, + wash_fluid=wash_fluid, + chamber=chamber, + waste_chamber_suck_time_after_sensor_change=waste_chamber_suck_time_after_sensor_change, + ) + + # TODO:(command:EK) Drain selected chamber + + async def drain_dual_chamber_system(self, pump_station: int = 1): + """Deprecated: use ``star.wash_station.drain()``.""" + return await self._wash_station.drain(station=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): + """Deprecated: use ``star.iswap.initialize()``.""" + return await self._iswap.initialize() + + async def position_components_for_free_iswap_y_range(self): + """Deprecated: use ``star.pip.backend.position_components_for_free_iswap_y_range()``.""" + return await self.driver.pip.position_components_for_free_iswap_y_range() + + async def move_iswap_x_relative(self, step_size: float, allow_splitting: bool = False): + """Deprecated: use ``star.iswap.backend.move_relative_x()``.""" + return await self._iswap.move_relative_x(step_size=step_size, allow_splitting=allow_splitting) + + async def move_iswap_y_relative(self, step_size: float, allow_splitting: bool = False): + """Deprecated: use ``star.iswap.backend.move_relative_y()``. + + Note: this legacy method includes a collision check against channel 0 that is not + present in the new API. Callers relying on that safety check should perform it + explicitly before calling ``move_relative_y``. + """ + # Legacy collision check — kept here because it uses legacy-only helpers. + 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" + ) + return await self._iswap.move_relative_y(step_size=step_size, allow_splitting=allow_splitting) + + async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = False): + """Deprecated: use ``star.iswap.backend.move_relative_z()``.""" + return await self._iswap.move_relative_z(step_size=step_size, allow_splitting=allow_splitting) + + async def move_iswap_x(self, x_position: float): + """Deprecated: use ``star.iswap.move_x()``.""" + return await self._iswap.move_x(x_position) + + async def move_iswap_y(self, y_position: float): + """Deprecated: use ``star.iswap.move_y()``.""" + return await self._iswap.move_y(y_position) + + async def move_iswap_z(self, z_position: float): + """Deprecated: use ``star.iswap.move_z()``.""" + return await self._iswap.move_z(z_position) + + async def open_not_initialized_gripper(self): + """Deprecated: use ``star.iswap.open_not_initialized_gripper()``.""" + return await self._iswap.open_not_initialized_gripper() + + async def iswap_open_gripper(self, open_position: Optional[float] = None): + """Open gripper. + + Deprecated: use ``star.iswap.open_gripper()``. + + Args: + open_position: Open position [mm]. Must be between 0 and 999.9. + Default 132.0 for iSWAP 4.0 (landscape), 91.0 for iSWAP 3 (portrait). + """ + + if open_position is None: + open_position = 91.0 if (await self.get_iswap_version()).startswith("3") else 132.0 + + assert 0 <= open_position <= 999.9, "open_position must be between 0 and 999.9" + + return await self._iswap.open_gripper(gripper_width=open_position) + + async def iswap_close_gripper( + self, + grip_strength: int = 5, + plate_width: float = 0, + plate_width_tolerance: float = 0, + ): + """Close gripper. + + Deprecated: use ``star.iswap.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]. Must be between 0 and 999.9. + plate_width_tolerance: Plate width tolerance [mm]. Must be between 0 and 9.9. Default 2.0. + """ + + assert 0 <= grip_strength <= 9, "grip_strength must be between 0 and 9" + assert 0 <= plate_width <= 999.9, "plate_width must be between 0 and 999.9" + assert 0 <= plate_width_tolerance <= 9.9, "plate_width_tolerance must be between 0 and 9.9" + + from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend + + return await self._iswap.close_gripper( + gripper_width=plate_width, + backend_params=iSWAPBackend.CloseGripperParams( + grip_strength=grip_strength, + plate_width_tolerance=plate_width_tolerance, + ), + ) + + # -------------- 3.17.2 Stack handling commands CP -------------- + + async def park_iswap( + self, + minimum_traverse_height_at_beginning_of_a_command: int = 2840, + ): + """Park the iSWAP. + + Deprecated: use ``star.iswap.park()``. + + 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 2840. + """ + + 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" + ) + + from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend + + return await self._iswap.park( + backend_params=iSWAPBackend.ParkParams( + minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command / 10, + ), + ) + + 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 request_iswap_rotation_drive_position_increments(self) -> int: + """Deprecated: use ``star.iswap.request_rotation_drive_position_increments()``.""" + return await self._iswap.request_rotation_drive_position_increments() + + async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": + """Deprecated: use ``star.iswap.request_rotation_drive_orientation()``.""" + new_orient = await self._iswap.request_rotation_drive_orientation() + return STARBackend.RotationDriveOrientation(new_orient.value) + + async def request_iswap_wrist_drive_position_increments(self) -> int: + """Deprecated: use ``star.iswap.request_wrist_drive_position_increments()``.""" + return await self._iswap.request_wrist_drive_position_increments() + + async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": + """Deprecated: use ``star.iswap.request_wrist_drive_orientation()``.""" + new_orient = await self._iswap.request_wrist_drive_orientation() + return STARBackend.WristDriveOrientation(new_orient.value) + + 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, + ): + """Deprecated: use ``star.iswap.rotate()``.""" + return await self._iswap.rotate( + rotation_drive=rotation_drive, # type: ignore[arg-type] + grip_direction=grip_direction, # type: ignore[arg-type] + gripper_velocity=gripper_velocity, + gripper_acceleration=gripper_acceleration, + gripper_protection=gripper_protection, + wrist_velocity=wrist_velocity, + wrist_acceleration=wrist_acceleration, + wrist_protection=wrist_protection, + ) + + async def iswap_dangerous_release_break(self): + """Deprecated: use ``star.iswap.dangerous_release_brake()``.""" + return await self._iswap.dangerous_release_brake() + + async def iswap_reengage_break(self): + """Deprecated: use ``star.iswap.reengage_brake()``.""" + return await self._iswap.reengage_brake() + + async def iswap_initialize_z_axis(self): + """Deprecated: use ``star.iswap.initialize_z_axis()``.""" + return await self._iswap.initialize_z_axis() + + 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, + ): + """Deprecated: use ``star.iswap.collapse_gripper_arm()``.""" + return await self._iswap.collapse_gripper_arm( + minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command / 10, + fold_up_at_end=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, + ): + """Deprecated: use ``star.iswap.prepare_teaching()``.""" + return await self._iswap.prepare_teaching( + x_position=x_position / 10, + x_direction=x_direction, + y_position=y_position / 10, + y_direction=y_direction, + z_position=z_position / 10, + z_direction=z_direction, + location=location, + hotel_depth=hotel_depth / 10, + grip_direction=grip_direction, + minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command / 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 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, + ): + """Deprecated: use ``star.iswap.get_logic_position()``.""" + return await self._iswap.get_logic_position( + x_position=x_position / 10, + x_direction=x_direction, + y_position=y_position / 10, + y_direction=y_direction, + z_position=z_position / 10, + z_direction=z_direction, + location=location, + hotel_depth=hotel_depth / 10, + grip_direction=grip_direction, + collision_control_level=collision_control_level, + ) + + # -------------- 3.17.6 iSWAP query -------------- + + async def request_iswap_in_parking_position(self): + """Deprecated: use ``star.iswap.request_in_parking_position()``.""" + return await self._iswap.request_in_parking_position() + + async def request_plate_in_iswap(self) -> bool: + """Deprecated: use ``star.iswap.is_gripper_closed()``.""" + return await self._iswap.is_gripper_closed() + + async def request_iswap_position(self) -> Coordinate: + """Deprecated: use ``star.iswap.get_gripper_location()``.""" + return (await self._iswap.request_gripper_location()).location + + async def iswap_rotation_drive_request_y(self) -> float: + """Deprecated: use ``star.iswap.rotation_drive_request_y()``.""" + return await self._iswap.rotation_drive_request_y() + + async def request_iswap_initialization_status(self) -> bool: + """Deprecated: use ``star.iswap.request_initialization_status()``.""" + return await self._iswap.request_initialization_status() + + async def request_iswap_version(self) -> str: + """Deprecated: use ``star.iswap.version`` (property, available after setup).""" + return await self._iswap._request_version() + + # -------------- 3.18 Cover and port control -------------- + + async def lock_cover(self): + """Deprecated: use ``star.cover.lock()``.""" + return await self._cover.lock() + + async def unlock_cover(self): + """Deprecated: use ``star.cover.unlock()``.""" + return await self._cover.unlock() + + async def disable_cover_control(self): + """Deprecated: use ``star.cover.disable()``.""" + return await self._cover.disable() + + async def enable_cover_control(self): + """Deprecated: use ``star.cover.enable()``.""" + return await self._cover.enable() + + async def set_cover_output(self, output: int = 1): + """Deprecated: use ``star.cover.set_output()``.""" + return await self._cover.set_output(output=output) + + async def reset_output(self, output: int = 1): + """Deprecated: use ``star.cover.reset_output()``.""" + return await self._cover.reset_output(output=output) + + async def request_cover_open(self) -> bool: + """Deprecated: use ``star.cover.is_open()``.""" + return await self._cover.is_open() + + # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- + + y_drive_mm_per_increment = 0.046302082 + 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, + probing_direction: Literal["right", "left"], + end_pos_search: Optional[float] = None, + post_detection_dist: float = 2.0, + tip_bottom_diameter: float = 1.2, + read_timeout: float = 240.0, + ) -> float: + """Deprecated: use ``star.driver.left_x_arm.clld_probe_x_position()``.""" + if self.driver.left_x_arm is None: + raise RuntimeError("left_x_arm not configured") + return await self.driver.left_x_arm.clld_probe_x_position( + channel_idx=channel_idx, + probing_direction=probing_direction, + end_pos_search=end_pos_search, + post_detection_dist=post_detection_dist, + tip_bottom_diameter=tip_bottom_diameter, + read_timeout=read_timeout, + ) + + async def clld_probe_y_position_using_channel( + self, + channel_idx: int, + probing_direction: Literal["forward", "backward"], + start_pos_search: Optional[float] = None, + end_pos_search: Optional[float] = None, + channel_speed: float = 10.0, + channel_acceleration_int: Literal[1, 2, 3, 4] = 4, + detection_edge: int = 10, + current_limit_int: Literal[1, 2, 3, 4, 5, 6, 7] = 7, + post_detection_dist: float = 2.0, + tip_bottom_diameter: float = 1.2, + ) -> float: + """Deprecated: use ``star.pip.backend.channels[n].clld_probe_y_position()``.""" + return await self.driver.pip.channels[channel_idx].clld_probe_y_position( + probing_direction=probing_direction, + start_pos_search=start_pos_search, + end_pos_search=end_pos_search, + channel_speed=channel_speed, + channel_acceleration_int=channel_acceleration_int, + detection_edge=detection_edge, + current_limit_int=current_limit_int, + post_detection_dist=post_detection_dist, + tip_bottom_diameter=tip_bottom_diameter, + ) + + 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 + ): + """Deprecated: use ``star.pip.backend.channels[n].search_z_using_clld()``.""" + return await self._pip_channels[channel_idx].search_z_using_clld( + lowest_immers_pos=lowest_immers_pos, + start_pos_search=start_pos_search, + 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, + ) + + async def clld_probe_z_height_using_channel( + self, + channel_idx: int, + 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: + """Deprecated: use ``star.pip.backend.channels[n].clld_probe_z_height()``.""" + return await self.driver.pip.channels[channel_idx].clld_probe_z_height( + lowest_immers_pos=lowest_immers_pos, + start_pos_search=start_pos_search, + 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, + move_channels_to_safe_pos_after=move_channels_to_safe_pos_after, + ) + + 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]: + """Deprecated: use ``star.pip.backend.channels[n].search_z_using_plld()``.""" + new_plld_mode: Optional[_NewPressureLLDMode] = None + if plld_mode is not None: + new_plld_mode = _NewPressureLLDMode(plld_mode.value) + return await self._pip_channels[channel_idx].search_z_using_plld( + lowest_immers_pos=lowest_immers_pos, + start_pos_search=start_pos_search, + 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=new_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, + ) + + async def plld_probe_z_height_using_channel( + self, + channel_idx: int, + lowest_immers_pos: float = 99.98, + start_pos_search: Optional[float] = None, + channel_speed_above_start_pos_search: float = 120.0, + channel_speed: float = 10.0, + channel_acceleration: float = 800.0, + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, + dispense_drive_acceleration: float = 0.2, + dispense_drive_max_speed: float = 14.5, + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, + clld_detection_edge: int = 10, + clld_detection_drop: int = 2, + max_delta_plld_clld: float = 5.0, + plld_mode: Optional[PressureLLDMode] = None, + plld_foam_detection_drop: int = 30, + plld_foam_detection_edge_tolerance: int = 30, + plld_foam_ad_values: int = 30, + plld_foam_search_speed: float = 10.0, + dispense_back_plld_volume: Optional[float] = None, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, + move_channels_to_safe_pos_after: bool = False, + ) -> Tuple[float, float]: + """Deprecated: use ``star.pip.backend.channels[n].plld_probe_z_height()``.""" + new_plld_mode: Optional[_NewPressureLLDMode] = None + if plld_mode is not None: + new_plld_mode = _NewPressureLLDMode(plld_mode.value) + return await self.driver.pip.channels[channel_idx].plld_probe_z_height( + lowest_immers_pos=lowest_immers_pos, + start_pos_search=start_pos_search, + 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=new_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, + move_channels_to_safe_pos_after=move_channels_to_safe_pos_after, + ) + + async def request_probe_z_position(self, channel_idx: int) -> float: + """Deprecated: use ``star.pip.backend.channels[n].request_probe_z_position()``.""" + return await self._pip_channels[channel_idx].request_probe_z_position() + + async def request_tip_len_on_channel(self, channel_idx: int) -> float: + """Deprecated: use ``star.pip.backend.channels[n].request_tip_length()``.""" + return await self._pip_channels[channel_idx].request_tip_length() + + 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 + + async def ztouch_probe_z_height_using_channel( + self, + channel_idx: int, + tip_len: Optional[float] = None, + lowest_immers_pos: float = 99.98, + start_pos_search: Optional[float] = None, + channel_speed: float = 10.0, + channel_acceleration: float = 800.0, + channel_speed_upwards: float = 125.0, + detection_limiter_in_PWM: int = 1, + push_down_force_in_PWM: int = 0, + post_detection_dist: float = 2.0, + move_channels_to_safe_pos_after: bool = False, + ) -> float: + """Deprecated: use ``star.pip.backend.channels[n].ztouch_probe_z_height()``.""" + return await self.driver.pip.channels[channel_idx].ztouch_probe_z_height( + tip_len=tip_len, + lowest_immers_pos=lowest_immers_pos, + start_pos_search=start_pos_search, + channel_speed=channel_speed, + channel_acceleration=channel_acceleration, + channel_speed_upwards=channel_speed_upwards, + detection_limiter_in_PWM=detection_limiter_in_PWM, + push_down_force_in_PWM=push_down_force_in_PWM, + post_detection_dist=post_detection_dist, + move_channels_to_safe_pos_after=move_channels_to_safe_pos_after, + ) + + class RotationDriveOrientation(enum.Enum): + LEFT = 1 + FRONT = 2 + RIGHT = 3 + PARKED_RIGHT = None + + async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): + """Deprecated: use ``star.iswap.rotate_rotation_drive()``.""" + return await self._iswap.rotate_rotation_drive(orientation) # type: ignore[arg-type] + + class WristDriveOrientation(enum.Enum): + RIGHT = 1 + STRAIGHT = 2 + LEFT = 3 + REVERSE = 4 + + async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): + """Deprecated: use ``star.iswap.rotate_wrist()``.""" + return await self._iswap.rotate_wrist(orientation) # type: ignore[arg-type] + + @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]: + """Deprecated: use ``star.pip.backend.get_channels_y_positions()``.""" + 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 + # less than their minimum spacing apart (typically 9 mm). (When you set channels using + # position_channels_in_y_direction, it will raise an error.) The minimum y is 6mm, + # so we fix that first (in case that value is misreported). Then, we traverse the + # list in reverse and enforce pairwise 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}=9mm. We start with the channel closest to `back_channel`, and make sure the + # channel behind it is at least 9mm, updating if needed. Iterating from the front (closest + # to `back_channel`) to the back (channel 0), all channels are put at the correct location. + # This order matters because the channel in front of any channel may have been moved in the + # previous iteration. + # Note that if a channel is already spaced at >=9mm, it is not moved. + for channel_idx in range(back_channel, 0, -1): + spacing = self._min_spacing_between(channel_idx - 1, channel_idx) + if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < spacing: + channel_locations[channel_idx - 1] = channel_locations[channel_idx] + spacing + + # Similarly for the channels to the front of `front_channel`, make sure they are all + # spaced >= channel_minimum_y_spacing (usually 9mm) apart. This time, we iterate from + # back (closest to `front_channel`) to the front (lh.backend.num_channels - 1), and + # put each channel >= channel_minimum_y_spacing before the one behind it. + for channel_idx in range(front_channel, self.num_channels - 1): + spacing = self._min_spacing_between(channel_idx, channel_idx + 1) + if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < spacing: + channel_locations[channel_idx + 1] = channel_locations[channel_idx] - spacing + + # Quick checks before movement. + if channel_locations[0] > 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]: + """Deprecated: use ``star.pip.backend.get_channels_z_positions()``.""" + 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]): + """Deprecated: use ``star.pip.backend.position_channels_in_z_direction()``.""" + 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, + ): + """Deprecated: use ``star.pip.backend.pierce_foil()``.""" + await self._pip.pierce_foil( + wells=wells, + piercing_channels=piercing_channels, + hold_down_channels=hold_down_channels, + move_inwards=move_inwards, + deck=self.deck, + spread=spread, + one_by_one=one_by_one, + distance_from_bottom=distance_from_bottom, + ) + + 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, + ): + """Deprecated: use ``star.pip.backend.step_off_foil()``.""" + await self._pip.step_off_foil( + wells=wells, + front_channel=front_channel, + back_channel=back_channel, + deck=self.deck, + move_inwards=move_inwards, + move_height=move_height, + ) + + async def request_volume_in_tip(self, channel: int) -> float: + """Deprecated: use ``star.pip.backend.channels[n].request_volume_in_tip()``.""" + return await self._pip_channels[channel].request_volume_in_tip() + + @asynccontextmanager + async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): + """Deprecated: use ``star.iswap.slow()``.""" + async with self._iswap.slow(wrist_velocity=wrist_velocity, gripper_velocity=gripper_velocity): + yield + + # ------------ 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): + """Deprecated: use ``star.pip.backend.channels[n].violently_shoot_down_tip()``.""" + return await self.star._pip_channels[channel_idx].violently_shoot_down_tip() + + +# 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..adc58a6d10e --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -0,0 +1,318 @@ +import copy +import datetime +import warnings +from contextlib import asynccontextmanager +from typing import Dict, List, Literal, Optional, Union + +from pylabrobot.legacy.liquid_handling.backends import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import ( + DriveConfiguration, + ExtendedConfiguration, + Head96Information, + MachineConfiguration, + STARBackend, +) +from pylabrobot.resources.well import Well + +_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, +) + + +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, + 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`. + 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 + + 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: + self._head96_information = Head96Information( + fw_version=datetime.date(2023, 1, 1), + supports_clot_monitoring_clld=False, + stop_disc_type="core_ii", + instrument_type="FM-STAR", + head_type="96 head II", + ) + else: + self._head96_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]: + print(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]: + print(command) + return None + + # # # # # # # # STAR configuration # # # # # # # # + + async def request_machine_configuration(self) -> MachineConfiguration: + return self._machine_configuration + + async def request_extended_configuration(self) -> ExtendedConfiguration: + assert self._extended_conf is not None + return self._extended_conf + + # # # # # # # # 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 move_channel_y(self, channel: int, y: float): + print(f"moving channel {channel} to y: {y}") + + async def move_channel_x(self, channel: int, x: float): + print(f"moving channel {channel} to x: {x}") + + async def move_all_channels_in_z_safety(self): + print("moving all channels to z safety") + + async def position_channels_in_z_direction(self, zs: Dict[int, float]): + print(f"positioning channels in z: {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, + ): + print( + f"stepping off foil | wells: {wells} | front channel: {front_channel} | " + f"back channel: {back_channel} | move inwards: {move_inwards} | move height: {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, + ): + print( + f"piercing foil | wells: {wells} | piercing channels: {piercing_channels} | " + f"hold down channels: {hold_down_channels} | move inwards: {move_inwards} | " + f"spread: {spread} | one by one: {one_by_one} | distance from bottom: {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) + + # # # # # # # # 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): + print("moving iswap x to", x_position) + + async def move_iswap_y(self, y_position: float): + print("moving iswap y to", y_position) + + async def move_iswap_z(self, z_position: float): + print("moving iswap z to", 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") + print(" | ".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): + print("positioning channels in y:", ys, "make_space:", make_space) + + async def request_pip_height_last_lld(self): + return list(range(12)) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py similarity index 94% rename from pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py index b478ff7b637..6630c28fd85 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py @@ -4,10 +4,11 @@ import unittest.mock from typing import Literal, cast -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.standard import GripDirection, Pickup -from pylabrobot.plate_reading import PlateReader -from pylabrobot.plate_reading.chatterbox import PlateReaderChatterboxBackend +from pylabrobot.hamilton.liquid_handlers.star.chatterbox import STARChatterboxDriver +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.standard import GripDirection, 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, @@ -149,21 +150,21 @@ class TestSTARUSBComms(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.star = STARBackend(read_timeout=1, packet_read_timeout=1) self.star.set_deck(STARLetDeck()) - self.star.io = unittest.mock.AsyncMock() + self.star.driver.io = unittest.mock.AsyncMock() await super().asyncSetUp() async def test_send_command_correct_response(self): - self.star.io.read.side_effect = [b"C0QMid0001"] + self.star.driver.io.read.side_effect = [b"C0QMid0001"] resp = await self.star.send_command("C0", command="QM", fmt="id####") self.assertEqual(resp, {"id": 1}) async def test_send_command_wrong_id(self): - self.star.io.read.side_effect = lambda: b"C0QMid0002" + self.star.driver.io.read.side_effect = lambda: b"C0QMid0002" with self.assertRaises(TimeoutError): await self.star.send_command("C0", command="QM", fmt="id####") async def test_send_command_plaintext_response(self): - self.star.io.read.side_effect = lambda: b"this is plaintext" + self.star.driver.io.read.side_effect = lambda: b"this is plaintext" with self.assertRaises(TimeoutError): await self.star.send_command("C0", command="QM", fmt="id####") @@ -174,12 +175,16 @@ class STARCommandCatcher(STARBackend): def __init__(self): super().__init__() + self.driver = STARChatterboxDriver(deck=STARLetDeck()) self.commands = [] async def setup(self) -> None: # type: ignore - self._num_channels = 8 - self._machine_conf = _DEFAULT_MACHINE_CONFIGURATION - self._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + await self.driver.setup() + self._machine_conf = self.driver.machine_conf # type: ignore[assignment] + self._extended_conf = self.driver.extended_conf # type: ignore[assignment] + self._num_channels = self.driver.num_channels + self._channels_minimum_y_spacing = self.driver._channels_minimum_y_spacing + self._pip_channels = self.driver.pip.channels self._core_parked = True async def send_command( # type: ignore @@ -207,11 +212,7 @@ class TestSTARLiquidHandlerCommands(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.STAR = STARBackend(read_timeout=1) - self.STAR._write_and_read_command = unittest.mock.AsyncMock() - self.STAR.io = unittest.mock.AsyncMock() - self.STAR.io.setup = unittest.mock.AsyncMock() - self.STAR.io.write = unittest.mock.MagicMock() - self.STAR.io.read = unittest.mock.MagicMock() + self.STAR.driver = STARChatterboxDriver(deck=STARLetDeck()) self.deck = STARLetDeck() self.lh = LiquidHandler(self.STAR, deck=self.deck) @@ -260,14 +261,30 @@ def __init__(self, name: str): self.maxDiff = None + await self.STAR.driver.setup() self.STAR._num_channels = 8 self.STAR._machine_conf = _DEFAULT_MACHINE_CONFIGURATION self.STAR._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + self.STAR._channels_minimum_y_spacing = self.STAR.driver._channels_minimum_y_spacing + self.STAR._pip_channels = self.STAR.driver.pip.channels self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True await self.lh.setup() + # After setup, restore the base send_command (instead of chatterbox's print-only override) + # so tests can mock _write_and_read_command and assert on calls. + import types + + from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler + + self.STAR.driver.send_command = types.MethodType( + HamiltonLiquidHandler.send_command, self.STAR.driver + ) + self.STAR._write_and_read_command = unittest.mock.AsyncMock(return_value=None) + self.STAR.driver.io = unittest.mock.AsyncMock() + self.STAR.driver.id_ = 0 # reset command counter so test IDs start at 1 + set_tip_tracking(enabled=False) async def test_core_read_barcode_success(self): @@ -468,7 +485,7 @@ async def test_tip_pickup_56(self): ), ] ) - self.STAR.io.write.reset_mock() + self.STAR.driver.io.write.reset_mock() async def test_tip_drop_56(self): await self.test_tip_pickup_56() # pick up tips first @@ -514,7 +531,7 @@ async def test_aspirate56(self): ) ] ) - self.STAR.io.write.reset_mock() + self.STAR.driver.io.write.reset_mock() async def test_single_channel_aspiration(self): self.lh.update_head_state({0: self.tip_rack.get_tip("A1")}) @@ -1080,7 +1097,8 @@ async def test_move_core(self): class STARIswapMovementTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.STAR = STARBackend() - self.STAR._write_and_read_command = unittest.mock.AsyncMock() + self.STAR.driver = STARChatterboxDriver(deck=STARLetDeck()) + self.STAR.driver._write_and_read_command = unittest.mock.AsyncMock() self.deck = STARLetDeck() self.lh = LiquidHandler(self.STAR, deck=self.deck) @@ -1091,14 +1109,27 @@ async def asyncSetUp(self): self.plt_car2 = PLT_CAR_P3AC_A01(name="plt_car2") self.deck.assign_child_resource(self.plt_car2, rails=3) + await self.STAR.driver.setup() self.STAR._num_channels = 8 self.STAR._machine_conf = _DEFAULT_MACHINE_CONFIGURATION self.STAR._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + self.STAR._channels_minimum_y_spacing = self.STAR.driver._channels_minimum_y_spacing + self.STAR._pip_channels = self.STAR.driver.pip.channels self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True await self.lh.setup() + import types + + from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler + + self.STAR.driver.send_command = types.MethodType( + HamiltonLiquidHandler.send_command, self.STAR.driver + ) + self.STAR._write_and_read_command = unittest.mock.AsyncMock(return_value=None) + self.STAR.driver.id_ = 0 + async def test_simple_movement(self): await self.lh.move_plate(self.plate, self.plt_car[1]) await self.lh.move_plate(self.plate, self.plt_car[0]) @@ -1207,7 +1238,8 @@ async def test_move_lid_across_rotated_resources(self): class STARFoilTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.star = STARBackend() - self.star._write_and_read_command = unittest.mock.AsyncMock() + self.star.driver = STARChatterboxDriver(deck=STARLetDeck()) + self.star.driver._write_and_read_command = unittest.mock.AsyncMock() self.deck = STARLetDeck() self.lh = LiquidHandler(backend=self.star, deck=self.deck) @@ -1220,14 +1252,30 @@ async def asyncSetUp(self): self.well = self.plate.get_well("A1") self.deck.assign_child_resource(plt_carrier, rails=10) + await self.star.driver.setup() self.star._num_channels = 8 self.star._machine_conf = _DEFAULT_MACHINE_CONFIGURATION self.star._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + self.star._channels_minimum_y_spacing = self.star.driver._channels_minimum_y_spacing + self.star._pip_channels = self.star.driver.pip.channels self.star.setup = unittest.mock.AsyncMock() self.star._core_parked = True self.star._iswap_parked = True await self.lh.setup() + # Restore base send_command and mock _write_and_read_command for test assertions. + import types + + from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler + + self.star.driver.send_command = types.MethodType( + HamiltonLiquidHandler.send_command, self.star.driver + ) + self.star._write_and_read_command = unittest.mock.AsyncMock(return_value=None) + self.star.driver._write_and_read_command = self.star._write_and_read_command + self.star.driver.io = unittest.mock.AsyncMock() + self.star.driver.id_ = 0 + await self.lh.pick_up_tips(self.tip_rack["A1:H1"]) async def test_pierce_foil_wide(self): @@ -1414,11 +1462,13 @@ class TestSTARTipPickupDropAllSizes(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = STARBackend() - self.backend._write_and_read_command = unittest.mock.AsyncMock() - self.backend.io = unittest.mock.AsyncMock() + self.backend.driver = STARChatterboxDriver(deck=STARLetDeck()) + await self.backend.driver.setup() self.backend._num_channels = 8 self.backend._machine_conf = _DEFAULT_MACHINE_CONFIGURATION self.backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + self.backend._channels_minimum_y_spacing = self.backend.driver._channels_minimum_y_spacing + self.backend._pip_channels = self.backend.driver.pip.channels self.backend.setup = unittest.mock.AsyncMock() self.backend._core_parked = True self.backend._iswap_parked = True @@ -1430,6 +1480,18 @@ async def asyncSetUp(self): self.deck.assign_child_resource(self.tip_car, rails=1) await self.lh.setup() + + import types + + from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler + + self.backend.driver.send_command = types.MethodType( + HamiltonLiquidHandler.send_command, self.backend.driver + ) + self.backend._write_and_read_command = unittest.mock.AsyncMock(return_value=None) + self.backend.driver.io = unittest.mock.AsyncMock() + self.backend.driver.id_ = 0 + set_tip_tracking(enabled=False) def _get_tp_tz_from_calls(self, cmd_prefix: str): @@ -1579,11 +1641,19 @@ async def test_can_reach_4ch_18mm_rejects_back_channel_too_far_back(self): def _make_star_backend(self, num_channels, spacings): """Helper: create a STARBackend with given channel count and spacings, mocking I/O.""" backend = STARBackend() + backend.driver = STARChatterboxDriver(deck=STARLetDeck(), num_channels=num_channels) + import types + + from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler + + backend.driver.send_command = types.MethodType( + HamiltonLiquidHandler.send_command, backend.driver + ) + backend._write_and_read_command = unittest.mock.AsyncMock(return_value=None) backend._num_channels = num_channels backend._channels_minimum_y_spacing = list(spacings) backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - backend.id_ = 0 - backend._write_and_read_command = unittest.mock.AsyncMock() + backend.driver.id_ = 0 backend.get_channels_y_positions = unittest.mock.AsyncMock() return backend @@ -1637,11 +1707,11 @@ class STARTestBase(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.STAR = STARBackend(read_timeout=1) - self.STAR._write_and_read_command = unittest.mock.AsyncMock() - self.STAR.io = unittest.mock.AsyncMock() - self.STAR.io.setup = unittest.mock.AsyncMock() - self.STAR.io.write = unittest.mock.MagicMock() - self.STAR.io.read = unittest.mock.MagicMock() + self.STAR.driver = STARChatterboxDriver(deck=STARLetDeck()) + self.STAR.driver.io = unittest.mock.AsyncMock() + self.STAR.driver.io.setup = unittest.mock.AsyncMock() + self.STAR.driver.io.write = unittest.mock.MagicMock() + self.STAR.driver.io.read = unittest.mock.MagicMock() self.deck = STARLetDeck() self.lh = LiquidHandler(self.STAR, deck=self.deck) @@ -1654,9 +1724,12 @@ async def asyncSetUp(self): self.plt_car[0] = self.plate = Cor_96_wellplate_360ul_Fb(name="plate_01") self.deck.assign_child_resource(self.plt_car, rails=9) + await self.STAR.driver.setup() self.STAR._num_channels = 8 self.STAR._machine_conf = _DEFAULT_MACHINE_CONFIGURATION self.STAR._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + self.STAR._channels_minimum_y_spacing = self.STAR.driver._channels_minimum_y_spacing + self.STAR._pip_channels = self.STAR.driver.pip.channels self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True 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 73ff83be6f7..a44666407fd 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/legacy/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py new file mode 100644 index 00000000000..c2a7eca8a09 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py @@ -0,0 +1,591 @@ +"""Hamilton Nimbus backend implementation (legacy wrapper). + +This module provides the NimbusBackend class for controlling Hamilton Nimbus +instruments via TCP communication using the Hamilton protocol. + +The implementation delegates to the v1b1 modules: +- Command classes: pylabrobot.hamilton.liquid_handlers.nimbus.commands +- PIP operations: pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend +- Door control: pylabrobot.hamilton.liquid_handlers.nimbus.door +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, Optional + +# Re-exported for backward compatibility (tests import these from this module) +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import ( # noqa: F401 + Aspirate, + Dispense, + DisableADC, + DropTips, + DropTipsRoll, + EnableADC, + GetChannelConfiguration, + GetChannelConfiguration_1, + InitializeSmartRoll, + IsDoorLocked, + IsInitialized, + IsTipPresent, + LockDoor, + NimbusTipType, + Park, + PickupTips, + PreInitializeSmart, + SetChannelConfiguration, + UnlockDoor, + _get_default_flow_rate, + _get_tip_type_from_tip, +) +from pylabrobot.hamilton.liquid_handlers.nimbus.door import NimbusDoor +from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import ( + NimbusPIPAspirateParams, + NimbusPIPBackend, + NimbusPIPDispenseParams, + NimbusPIPDropTipsParams, + NimbusPIPPickUpTipsParams, +) +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend +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 +from pylabrobot.resources.hamilton import HamiltonTip, TipSize # noqa: F401 +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + +logger = logging.getLogger(__name__) + + +class NimbusBackend(HamiltonTCPBackend): + """Backend for Hamilton Nimbus liquid handling instruments. + + This backend uses TCP communication with the Hamilton protocol to control + Nimbus instruments. It delegates pipetting operations and door control to + the v1b1 implementation while maintaining the legacy API. + + Attributes: + _door_lock_available: Whether door lock is available on this instrument. + """ + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + """Initialize Nimbus backend. + + Args: + host: Hamilton instrument IP address + port: Hamilton instrument port (default: 2000) + 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__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + ) + + self._num_channels: Optional[int] = None + self._pipette_address: Optional[Address] = None + self._door_lock_address: Optional[Address] = None + self._nimbus_core_address: Optional[Address] = None + self._is_initialized: Optional[bool] = None + self._channel_configurations: Optional[Dict[int, Dict[int, bool]]] = None + + self._channel_traversal_height: float = 146.0 # Default traversal height in mm + + # v1b1 delegates (created in setup) + self._pip_backend: Optional[NimbusPIPBackend] = None + self._door: Optional[NimbusDoor] = None + + async def setup(self, unlock_door: bool = False, force_initialize: bool = False): + """Set up the Nimbus backend. + + This method: + 1. Establishes TCP connection and performs protocol initialization + 2. Discovers instrument objects + 3. Queries channel configuration to get num_channels + 4. Queries tip presence + 5. Queries initialization status + 6. Locks door if available + 7. Conditionally initializes NimbusCore with InitializeSmartRoll (only if not initialized) + 8. Optionally unlocks door after initialization + + Args: + unlock_door: If True, unlock door after initialization (default: False) + force_initialize: If True, force initialization even if already initialized + """ + # Call parent setup (TCP connection, Protocol 7 init, Protocol 3 registration) + await super().setup() + + # Discover instrument objects + await self._discover_instrument_objects() + + # Ensure required objects are discovered + if self._pipette_address is None: + raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") + + # Query channel configuration to get num_channels + try: + config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) + assert config is not None, "GetChannelConfiguration_1 command returned None" + self._num_channels = config["channels"] + logger.info(f"Channel configuration: {config['channels']} channels") + except Exception as e: + logger.error(f"Failed to query channel configuration: {e}") + raise + + # Create v1b1 PIP backend delegate + assert isinstance(self.deck, NimbusDeck), "Nimbus requires a NimbusDeck" + self._pip_backend = NimbusPIPBackend( + driver=self, # type: ignore[arg-type] # legacy backend duck-types as driver + deck=self.deck, + address=self._pipette_address, + num_channels=self._num_channels, + traversal_height=self._channel_traversal_height, + ) + + # Create v1b1 door delegate + if self._door_lock_address is not None: + self._door = NimbusDoor( + driver=self, # type: ignore[arg-type] + address=self._door_lock_address, + ) + + # Query tip presence + try: + tip_present = await self.request_tip_presence() + logger.info(f"Tip presence: {tip_present}") + except Exception as e: + logger.warning(f"Failed to query tip presence: {e}") + + # Query initialization status + try: + init_status = await self.send_command(IsInitialized(self._nimbus_core_address)) + assert init_status is not None, "IsInitialized command returned None" + self._is_initialized = init_status.get("initialized", False) + logger.info(f"Instrument initialized: {self._is_initialized}") + except Exception as e: + logger.error(f"Failed to query initialization status: {e}") + raise + + # Lock door if available + if self._door is not None: + try: + if not await self.is_door_locked(): + await self.lock_door() + else: + logger.info("Door already locked") + except RuntimeError: + logger.warning("Door lock operations skipped (not available or not set up)") + except Exception as e: + logger.warning(f"Failed to lock door: {e}") + + # Conditional initialization - only if not already initialized + if not self._is_initialized or force_initialize: + # Set channel configuration for each channel + try: + for channel in range(1, self.num_channels + 1): + await self.send_command( + SetChannelConfiguration( + dest=self._pipette_address, + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) + ) + logger.info(f"Channel configuration set for {self.num_channels} channels") + except Exception as e: + logger.error(f"Failed to set channel configuration: {e}") + raise + + # Initialize NimbusCore with InitializeSmartRoll using waste positions + try: + all_channels = list(range(self.num_channels)) + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._pip_backend._build_waste_position_params(use_channels=all_channels) + + await self.send_command( + InitializeSmartRoll( + dest=self._nimbus_core_address, + x_positions=x_positions_full, + y_positions=y_positions_full, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + ) + logger.info("NimbusCore initialized with InitializeSmartRoll successfully") + self._is_initialized = True + except Exception as e: + logger.error(f"Failed to initialize NimbusCore with InitializeSmartRoll: {e}") + raise + else: + logger.info("Instrument already initialized, skipping initialization") + + # Unlock door if requested + if unlock_door and self._door is not None: + try: + await self.unlock_door() + except RuntimeError: + logger.warning("Door unlock requested but not available or not set up") + except Exception as e: + logger.warning(f"Failed to unlock door: {e}") + + async def _discover_instrument_objects(self): + """Discover instrument-specific objects using introspection.""" + introspection = HamiltonIntrospection(self) + + root_objects = self._discovered_objects.get("root", []) + if not root_objects: + logger.warning("No root objects discovered") + return + + nimbus_core_addr = root_objects[0] + self._nimbus_core_address = nimbus_core_addr + + try: + core_info = await introspection.get_object(nimbus_core_addr) + + for i in range(core_info.subobject_count): + try: + sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) + sub_info = await introspection.get_object(sub_addr) + + if sub_info.name == "Pipette": + self._pipette_address = sub_addr + logger.info(f"Found Pipette at {sub_addr}") + + if sub_info.name == "DoorLock": + self._door_lock_address = sub_addr + logger.info(f"Found DoorLock at {sub_addr}") + + except Exception as e: + logger.debug(f"Failed to get subobject {i}: {e}") + + except Exception as e: + logger.warning(f"Failed to discover instrument objects: {e}") + + if self._door_lock_address is None: + logger.info("DoorLock not available on this instrument") + + def _fill_by_channels(self, values, use_channels, default): + """Delegate to PIP backend.""" + assert self._pip_backend is not None, "Call setup() first." + return self._pip_backend._fill_by_channels(values, use_channels, default) + + @property + def num_channels(self) -> int: + """The number of channels that the robot has.""" + if self._num_channels is None: + raise RuntimeError("num_channels not set. Call setup() first to query from instrument.") + return self._num_channels + + def set_minimum_channel_traversal_height(self, traversal_height: float): + """Set the minimum traversal height for the channels.""" + if not 0 < traversal_height < 146: + raise ValueError(f"Traversal height must be between 0 and 146 mm (got {traversal_height})") + self._channel_traversal_height = traversal_height + if self._pip_backend is not None: + self._pip_backend.traversal_height = traversal_height + + async def park(self): + """Park the instrument.""" + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore address not discovered. Call setup() first.") + try: + await self.send_command(Park(self._nimbus_core_address)) + logger.info("Instrument parked successfully") + except Exception as e: + logger.error(f"Failed to park instrument: {e}") + raise + + def _ensure_door(self) -> NimbusDoor: + """Get or lazily create the door delegate.""" + if self._door is not None: + return self._door + if self._door_lock_address is not None: + self._door = NimbusDoor(driver=self, address=self._door_lock_address) # type: ignore[arg-type] + return self._door + raise RuntimeError( + "Door lock is not available on this instrument or setup() has not been called." + ) + + async def is_door_locked(self) -> bool: + """Check if the door is locked.""" + return await self._ensure_door().is_locked() + + async def lock_door(self) -> None: + """Lock the door.""" + await self._ensure_door().lock() + + async def unlock_door(self) -> None: + """Unlock the door.""" + await self._ensure_door().unlock() + + async def stop(self): + """Stop the backend and close connection.""" + await HamiltonTCPBackend.stop(self) + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Request tip presence on each channel.""" + if self._pip_backend is None: + # Fallback for calls during setup before pip_backend is created + if self._pipette_address is None: + raise RuntimeError("Pipette address not discovered. Call setup() first.") + tip_status = await self.send_command(IsTipPresent(self._pipette_address)) + assert tip_status is not None, "IsTipPresent command returned None" + return [bool(v) for v in tip_status.get("tip_present", [])] + return await self._pip_backend.request_tip_presence() + + # -- Pipetting operations: delegate to NimbusPIPBackend --------------------- + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + ): + """Pick up tips from the specified resource. + + Args: + ops: List of Pickup operations, one per channel + use_channels: List of channel indices to use + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (optional, defaults to _channel_traversal_height) + """ + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.pick_up_tips( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPPickUpTipsParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + ), + ) + + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + default_waste: bool = False, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_end_of_a_command: Optional[float] = None, + roll_distance: Optional[float] = None, + ): + """Drop tips to the specified resource. + + Args: + ops: List of Drop operations, one per channel + use_channels: List of channel indices to use + default_waste: For DropTips command, if True, drop to default waste + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + z_position_at_end_of_a_command: Z final position in mm (absolute) + roll_distance: Roll distance in mm (defaults to 9.0 mm for waste positions) + """ + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.drop_tips( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPDropTipsParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + default_waste=default_waste, + z_position_at_end_of_a_command=z_position_at_end_of_a_command, + roll_distance=roll_distance, + ), + ) + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + adc_enabled: bool = False, + lld_mode: Optional[List[int]] = None, + lld_search_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + gamma_lld_sensitivity: Optional[List[int]] = None, + dp_lld_sensitivity: Optional[List[int]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + limit_curve_index: Optional[List[int]] = None, + tadm_enabled: bool = False, + ): + """Aspirate liquid from the specified resource. + + Args: + ops: List of SingleChannelAspiration operations, one per channel + use_channels: List of channel indices to use + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + adc_enabled: Enable ADC (Automatic Drip Control) + lld_mode: LLD mode per channel (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL) + lld_search_height: Relative offset from well bottom for LLD search (mm) + immersion_depth: Depth to submerge into liquid (mm) + surface_following_distance: Distance to follow liquid surface (mm) + gamma_lld_sensitivity: Gamma LLD sensitivity per channel (1-4) + dp_lld_sensitivity: DP LLD sensitivity per channel (1-4) + settling_time: Settling time per channel (s), default 1.0 + transport_air_volume: Transport air volume per channel (uL), default 5.0 + pre_wetting_volume: Pre-wetting volume per channel (uL) + swap_speed: Swap speed on leaving liquid per channel (uL/s), default 20.0 + mix_position_from_liquid_surface: Mix position from surface per channel (mm) + limit_curve_index: Limit curve index per channel + tadm_enabled: TADM enabled flag + """ + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.aspirate( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPAspirateParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + adc_enabled=adc_enabled, + lld_mode=lld_mode, + lld_search_height=lld_search_height, + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + pre_wetting_volume=pre_wetting_volume, + swap_speed=swap_speed, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + limit_curve_index=limit_curve_index, + tadm_enabled=tadm_enabled, + ), + ) + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + adc_enabled: bool = False, + lld_mode: Optional[List[int]] = None, + lld_search_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + gamma_lld_sensitivity: Optional[List[int]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + limit_curve_index: Optional[List[int]] = None, + tadm_enabled: bool = False, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + side_touch_off_distance: float = 0.0, + dispense_offset: Optional[List[float]] = None, + ): + """Dispense liquid to the specified resource. + + Args: + ops: List of SingleChannelDispense operations, one per channel + use_channels: List of channel indices to use + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + adc_enabled: Enable ADC (Automatic Drip Control) + lld_mode: LLD mode per channel (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL) + lld_search_height: Relative offset from well bottom for LLD search (mm) + immersion_depth: Depth to submerge into liquid (mm) + surface_following_distance: Distance to follow liquid surface (mm) + gamma_lld_sensitivity: Gamma LLD sensitivity per channel (1-4) + settling_time: Settling time per channel (s), default 1.0 + transport_air_volume: Transport air volume per channel (uL), default 5.0 + swap_speed: Swap speed on leaving liquid per channel (uL/s), default 20.0 + mix_position_from_liquid_surface: Mix position from surface per channel (mm) + limit_curve_index: Limit curve index per channel + tadm_enabled: TADM enabled flag + cut_off_speed: Cut off speed per channel (uL/s), default 25.0 + stop_back_volume: Stop back volume per channel (uL) + side_touch_off_distance: Side touch off distance (mm) + dispense_offset: Dispense offset per channel (mm) + """ + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.dispense( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPDispenseParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + adc_enabled=adc_enabled, + lld_mode=lld_mode, + lld_search_height=lld_search_height, + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + gamma_lld_sensitivity=gamma_lld_sensitivity, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + swap_speed=swap_speed, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + limit_curve_index=limit_curve_index, + tadm_enabled=tadm_enabled, + cut_off_speed=cut_off_speed, + stop_back_volume=stop_back_volume, + side_touch_off_distance=side_touch_off_distance, + dispense_offset=dispense_offset, + ), + ) + + # -- Unimplemented abstract methods ---------------------------------------- + + async def pick_up_tips96(self, pickup: PickupTipRack): + raise NotImplementedError("pick_up_tips96 not yet implemented") + + async def drop_tips96(self, drop: DropTipRack): + raise NotImplementedError("drop_tips96 not yet implemented") + + async def aspirate96(self, aspiration: MultiHeadAspirationPlate | MultiHeadAspirationContainer): + raise NotImplementedError("aspirate96 not yet implemented") + + async def dispense96(self, dispense: MultiHeadDispensePlate | MultiHeadDispenseContainer): + raise NotImplementedError("dispense96 not yet implemented") + + async def pick_up_resource(self, pickup: ResourcePickup): + raise NotImplementedError("pick_up_resource not yet implemented") + + async def move_picked_up_resource(self, move: ResourceMove): + raise NotImplementedError("move_picked_up_resource not yet implemented") + + async def drop_resource(self, drop: ResourceDrop): + raise NotImplementedError("drop_resource not yet implemented") + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Check if the tip can be picked up by the specified channel.""" + assert self._pip_backend is not None, "Call setup() first." + return self._pip_backend.can_pick_up_tip(channel_idx, tip) 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 97% rename from pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 75c71692f91..12a1c73dfaa 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, @@ -568,8 +571,7 @@ async def test_set_minimum_channel_traversal_height_invalid(self): backend.set_minimum_channel_traversal_height(-10) async def test_fill_by_channels(self): - backend = NimbusBackend(host="192.168.1.100") - backend._num_channels = 8 + backend = _setup_backend() # Test with channels 0, 2, 4 values = [100, 200, 300] @@ -580,8 +582,7 @@ async def test_fill_by_channels(self): self.assertEqual(result, expected) async def test_fill_by_channels_mismatched_lengths(self): - backend = NimbusBackend(host="192.168.1.100") - backend._num_channels = 8 + backend = _setup_backend() with self.assertRaises(ValueError): backend._fill_by_channels([1, 2], [0, 1, 2], default=0) @@ -604,12 +605,25 @@ def _mock_send_command_response(command) -> Optional[dict]: def _setup_backend() -> NimbusBackend: """Create a NimbusBackend with pre-configured state for testing.""" + from pylabrobot.hamilton.liquid_handlers.nimbus.door import NimbusDoor + from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import NimbusPIPBackend + backend = NimbusBackend(host="192.168.1.100", port=2000) backend._num_channels = 8 backend._pipette_address = Address(1, 1, 257) backend._door_lock_address = Address(1, 1, 268) backend._nimbus_core_address = Address(1, 1, 48896) backend._is_initialized = True + backend._pip_backend = NimbusPIPBackend( + driver=backend, # type: ignore[arg-type] + deck=NimbusDeck(), + address=Address(1, 1, 257), + num_channels=8, + ) + backend._door = NimbusDoor( + driver=backend, # type: ignore[arg-type] + address=Address(1, 1, 268), + ) return backend @@ -617,6 +631,8 @@ def _setup_backend_with_deck(deck: NimbusDeck) -> NimbusBackend: """Create a NimbusBackend with pre-configured state and deck for testing.""" backend = _setup_backend() backend._deck = deck + assert backend._pip_backend is not None + backend._pip_backend.deck = deck return backend @@ -657,6 +673,7 @@ async def test_park(self): async def test_door_methods_without_address_raise(self): self.backend._door_lock_address = None + self.backend._door = None with self.assertRaises(RuntimeError): await self.backend.lock_door() diff --git a/pylabrobot/liquid_handling/backends/hamilton/planning.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/planning.py similarity index 96% rename from pylabrobot/liquid_handling/backends/hamilton/planning.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/planning.py index dfba2fa0a5f..7e14116bad1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/planning.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/planning.py @@ -1,6 +1,6 @@ from typing import Callable, Dict, List -from pylabrobot.liquid_handling.channel_positioning import MIN_SPACING_BETWEEN_CHANNELS +from pylabrobot.legacy.liquid_handling.channel_positioning import MIN_SPACING_BETWEEN_CHANNELS from pylabrobot.resources import Coordinate diff --git a/pylabrobot/liquid_handling/backends/hamilton/planning_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/planning_tests.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/planning_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/planning_tests.py 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/legacy/liquid_handling/backends/hamilton/tcp/__init__.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py new file mode 100644 index 00000000000..19c2516c46a --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py @@ -0,0 +1,31 @@ +"""Compatibility shims — canonical location is pylabrobot.hamilton.tcp.""" + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand as HamiltonCommand +from pylabrobot.hamilton.tcp.introspection import ( + HamiltonIntrospection as HamiltonIntrospection, +) +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage as CommandMessage, + CommandResponse as CommandResponse, + HoiParams as HoiParams, + HoiParamsParser as HoiParamsParser, + InitMessage as InitMessage, + InitResponse as InitResponse, + RegistrationMessage as RegistrationMessage, + RegistrationResponse as RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import ( + Address as Address, + HarpPacket as HarpPacket, + HoiPacket as HoiPacket, + IpPacket as IpPacket, +) +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action as Hoi2Action, + HamiltonDataType as HamiltonDataType, + HamiltonProtocol as HamiltonProtocol, + HarpTransportableProtocol as HarpTransportableProtocol, + HoiRequestId as HoiRequestId, + RegistrationActionCode as RegistrationActionCode, + RegistrationOptionType as RegistrationOptionType, +) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py new file mode 100644 index 00000000000..23d6a54fce8 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py @@ -0,0 +1,3 @@ +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.commands.""" + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand as HamiltonCommand # noqa: F401 diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py new file mode 100644 index 00000000000..17156ca7aaf --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py @@ -0,0 +1,21 @@ +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.introspection.""" + +from pylabrobot.hamilton.tcp.introspection import ( # noqa: F401 + EnumInfo, + GetEnumsCommand, + GetInterfacesCommand, + GetMethodCommand, + GetObjectCommand, + GetStructsCommand, + GetSubobjectAddressCommand, + HamiltonIntrospection, + InterfaceInfo, + MethodInfo, + ObjectInfo, + StructInfo, + get_introspection_type_category, + is_complex_introspection_type, + resolve_introspection_type_name, + resolve_type_id, + resolve_type_ids, +) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py new file mode 100644 index 00000000000..701da6d33f9 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py @@ -0,0 +1,12 @@ +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.messages.""" + +from pylabrobot.hamilton.tcp.messages import ( # noqa: F401 + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py new file mode 100644 index 00000000000..83a4d1e679b --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py @@ -0,0 +1,14 @@ +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.packets.""" + +from pylabrobot.hamilton.tcp.packets import ( # noqa: F401 + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + Address, + ConnectionPacket, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, + decode_version_byte, + encode_version_byte, +) 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..e6989ffefc7 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py @@ -0,0 +1,13 @@ +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.protocol.""" + +from pylabrobot.hamilton.tcp.protocol import ( # noqa: F401 + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + HamiltonDataType, + HamiltonProtocol, + HarpTransportableProtocol, + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) 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 b7c95621fc0..3ca80f942bb 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..e99642e860e --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py @@ -0,0 +1,709 @@ +import uuid +from typing import Dict, List, Optional, Tuple, Union, cast + +from pylabrobot import utils +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 + + # for run cancellation + import ot_api.requestor as _req + + 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" + + +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 + + ot_api.set_host(host) + ot_api.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 = ot_api.runs.create() + ot_api.set_run(run_id) + + # get pipettes, then assign them + self.left_pipette, self.right_pipette = ot_api.lh.add_mounted_pipettes() + + self.left_pipette_has_tip = self.right_pipette_has_tip = False + + # get api version + health = ot_api.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(ot_api, "run_id", None) + if run_id: + try: + _req.post(f"/runs/{run_id}/cancel") + except Exception: + try: + _req.post(f"/runs/{run_id}/actions/cancel") + except Exception: + try: + _req.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 = ot_api.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 + assert isinstance(deck, OTDeck) + slot = deck.get_slot(tip_rack) + assert slot is not None, "tip rack must be on deck" + + ot_api.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 + + ot_api.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: + ot_api.lh.move_to_addressable_area_for_drop_tip( + pipette_id=pipette_id, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + ) + ot_api.lh.drop_tip_in_place(pipette_id=pipette_id) + else: + ot_api.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] + + 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 = ( + 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): + ot_api.lh.aspirate_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + ot_api.lh.dispense_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + + ot_api.lh.aspirate_in_place( + volume=volume, + flow_rate=flow_rate, + pipette_id=pipette_id, + ) + + traversal_location = ( + 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 = ( + 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, + ) + + ot_api.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): + ot_api.lh.aspirate_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + ot_api.lh.dispense_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + + traversal_location = ( + 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): + ot_api.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], ot_api.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 = ot_api.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.") + + ot_api.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 97% rename from pylabrobot/liquid_handling/backends/opentrons_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py index 05ea8e2845f..a724c02d996 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py @@ -5,12 +5,12 @@ 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 ( 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_simulator.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py similarity index 95% rename from pylabrobot/liquid_handling/backends/opentrons_simulator.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py index 914ba848ea3..1008a7b75f2 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, 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 b15dd9fd589..f77a2c994f3 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 535dc8a8681..0e71b2416ed 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 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 98% rename from pylabrobot/liquid_handling/channel_positioning_tests.py rename to pylabrobot/legacy/liquid_handling/channel_positioning_tests.py index 78f9fa6612e..e02951ee834 100644 --- a/pylabrobot/liquid_handling/channel_positioning_tests.py +++ b/pylabrobot/legacy/liquid_handling/channel_positioning_tests.py @@ -1,6 +1,6 @@ import unittest -from pylabrobot.liquid_handling.channel_positioning import ( +from pylabrobot.legacy.liquid_handling.channel_positioning import ( _centers_to_offsets, _distribute_channels, _get_compartments, @@ -11,7 +11,7 @@ compute_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 diff --git a/pylabrobot/legacy/liquid_handling/errors.py b/pylabrobot/legacy/liquid_handling/errors.py new file mode 100644 index 00000000000..b78b9fc6c04 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/errors.py @@ -0,0 +1,15 @@ +# Re-export shared error classes from capabilities so that isinstance checks +# using the canonical capabilities types work correctly for legacy backends too. +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError, NoChannelError + + +class ChannelsDoNotFitError(Exception): + """Raised when channels cannot be positioned within a resource's compartments while respecting + no-go zones and spacing constraints.""" + + +__all__ = [ + "ChannelizedError", + "ChannelsDoNotFitError", + "NoChannelError", +] 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..fc354dcdf4a --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/__init__.py @@ -0,0 +1,8 @@ +# Legacy shim: re-exports from the new canonical locations. +from pylabrobot.hamilton.lh.vantage.liquid_classes import get_vantage_liquid_class # noqa: F401 +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass # noqa: F401 +from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import ( + get_star_liquid_class, # noqa: F401 +) + +__all__ = ["HamiltonLiquidClass", "get_star_liquid_class", "get_vantage_liquid_class"] 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..fc354dcdf4a --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/__init__.py @@ -0,0 +1,8 @@ +# Legacy shim: re-exports from the new canonical locations. +from pylabrobot.hamilton.lh.vantage.liquid_classes import get_vantage_liquid_class # noqa: F401 +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass # noqa: F401 +from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import ( + get_star_liquid_class, # noqa: F401 +) + +__all__ = ["HamiltonLiquidClass", "get_star_liquid_class", "get_vantage_liquid_class"] diff --git a/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py new file mode 100644 index 00000000000..19f627b2e42 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py @@ -0,0 +1,107 @@ +from typing import Any, Dict + +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.interpolation import interpolate_1d + + +class HamiltonLiquidClass(SerializableMixin): + """A liquid class like used in VENUS / Venus on Vantage.""" + + def __init__( + self, + curve: Dict[float, float], + aspiration_flow_rate: float, + aspiration_mix_flow_rate: float, + aspiration_air_transport_volume: float, + aspiration_blow_out_volume: float, + aspiration_swap_speed: float, + aspiration_settling_time: float, + aspiration_over_aspirate_volume: float, + aspiration_clot_retract_height: float, + dispense_flow_rate: float, + dispense_mode: float, + dispense_mix_flow_rate: float, + dispense_air_transport_volume: float, + dispense_blow_out_volume: float, + dispense_swap_speed: float, + dispense_settling_time: float, + dispense_stop_flow_rate: float, + dispense_stop_back_volume: float, + ): + self.curve = curve + + self.aspiration_flow_rate = aspiration_flow_rate + self.aspiration_mix_flow_rate = aspiration_mix_flow_rate + self.aspiration_air_transport_volume = aspiration_air_transport_volume + self.aspiration_blow_out_volume = aspiration_blow_out_volume + self.aspiration_swap_speed = aspiration_swap_speed + self.aspiration_settling_time = aspiration_settling_time + self.aspiration_over_aspirate_volume = aspiration_over_aspirate_volume + self.aspiration_clot_retract_height = aspiration_clot_retract_height + + self.dispense_mode = dispense_mode + self.dispense_flow_rate = dispense_flow_rate + self.dispense_mix_flow_rate = dispense_mix_flow_rate + self.dispense_air_transport_volume = dispense_air_transport_volume + self.dispense_blow_out_volume = dispense_blow_out_volume + self.dispense_swap_speed = dispense_swap_speed + self.dispense_settling_time = dispense_settling_time + self.dispense_stop_flow_rate = dispense_stop_flow_rate + self.dispense_stop_back_volume = dispense_stop_back_volume + + def compute_corrected_volume(self, target_volume: float) -> float: + """Compute the piston displacement volume needed to achieve a desired liquid volume. + + This method determines how far the pipette piston must move (i.e., the commanded + internal volume) to aspirate or dispense a specified *target liquid volume*. + Because factors such as air compressibility, liquid viscosity, and tip geometry + affect the relationship between piston displacement and actual transferred volume, + Hamilton liquid classes use an empirically derived **correction curve**. + + The correction curve maps *nominal liquid volumes* (target) to the corresponding + *piston displacement volumes* required to achieve them. If the requested + `target_volume` exactly matches a calibration point, its mapped value is used. + Otherwise, the function performs **piecewise linear interpolation** between the + two nearest calibration points. For values outside the calibration range, + the nearest segment is linearly extrapolated. + + This interpolation is performed using + :func:`pylabrobot.utils.interpolation.interpolate_1d`. + + Args: + target_volume: The liquid volume to be aspirated or dispensed (in µL). + + Returns: + The corrected piston displacement volume (in µL) that the pipette mechanism + must execute to achieve the desired liquid transfer. + + Raises: + ValueError: If the correction curve data is invalid or non-numeric. + """ + if self.curve is None: + return target_volume + + return interpolate_1d(target_volume, self.curve, bounds_handling="extrapolate") + + def serialize(self) -> Dict[str, Any]: + """Serialize the liquid class to a dictionary.""" + return { + "curve": self.curve, + "aspiration_flow_rate": self.aspiration_flow_rate, + "aspiration_mix_flow_rate": self.aspiration_mix_flow_rate, + "aspiration_air_transport_volume": self.aspiration_air_transport_volume, + "aspiration_blow_out_volume": self.aspiration_blow_out_volume, + "aspiration_swap_speed": self.aspiration_swap_speed, + "aspiration_settling_time": self.aspiration_settling_time, + "aspiration_over_aspirate_volume": self.aspiration_over_aspirate_volume, + "aspiration_clot_retract_height": self.aspiration_clot_retract_height, + "dispense_mode": self.dispense_mode, + "dispense_flow_rate": self.dispense_flow_rate, + "dispense_mix_flow_rate": self.dispense_mix_flow_rate, + "dispense_air_transport_volume": self.dispense_air_transport_volume, + "dispense_blow_out_volume": self.dispense_blow_out_volume, + "dispense_swap_speed": self.dispense_swap_speed, + "dispense_settling_time": self.dispense_settling_time, + "dispense_stop_flow_rate": self.dispense_stop_flow_rate, + "dispense_stop_back_volume": self.dispense_stop_back_volume, + } 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..e0afe8ccb6d --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py @@ -0,0 +1,15005 @@ +from typing import Dict, Optional, Tuple + +from pylabrobot.hamilton.liquid_handlers.liquid_class 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..2bce6d2b03b 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.hamilton.liquid_handlers.liquid_class 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/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py new file mode 100644 index 00000000000..8c8931d9606 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -0,0 +1,1846 @@ +from __future__ import annotations + +import contextlib +import inspect +import json +import unittest.mock +import warnings +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Generator, + List, + Literal, + Optional, + Sequence, + Set, + Tuple, + Union, +) + +from pylabrobot.capabilities.arms.backend import OrientableGripperArmBackend +from pylabrobot.capabilities.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.arms.standard import GripDirection as _NewGripDirection +from pylabrobot.capabilities.arms.standard import CartesianPose +from pylabrobot.capabilities.liquid_handling.head96 import Head96 +from pylabrobot.capabilities.liquid_handling.head96_backend import ( + Head96Backend as _NewHead96Backend, +) +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.capabilities.liquid_handling.pip_backend import ( + PIPBackend as _NewLHBackend, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Aspiration as _NewAspiration, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Dispense as _NewDispense, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + DropTipRack as _NewDropTipRack, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Mix as _NewMix, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadAspirationContainer as _NewMHAC, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadAspirationPlate as _NewMHAP, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadDispenseContainer as _NewMHDC, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadDispensePlate as _NewMHDP, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Pickup as _NewPickup, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + PickupTipRack as _NewPickupTipRack, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + TipDrop as _NewTipDrop, +) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.strictness import ( + Strictness, + get_strictness, +) +from pylabrobot.legacy.liquid_handling.utils import ( + get_tight_single_resource_liquid_op_offsets, +) +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished +from pylabrobot.resources import ( + Container, + Coordinate, + Deck, + Lid, + Plate, + PlateAdapter, + Resource, + ResourceHolder, + ResourceStack, + Tip, + TipRack, + TipSpot, + TipTracker, + Trash, + Well, +) +from pylabrobot.resources.rotation import Rotation +from pylabrobot.serializer import deserialize, serialize + +from .backends import LiquidHandlerBackend +from .standard import ( + Drop, + DropTipRack, + GripDirection, + Mix, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) + +TipPresenceProbingMethod = Callable[ + [List[TipSpot], Optional[List[int]]], + Awaitable[Dict[str, bool]], +] + + +def _convert_mix(new_mix) -> Optional[Mix]: + """Convert a new-style Mix to a legacy Mix.""" + if new_mix is None: + return None + return Mix(volume=new_mix.volume, repetitions=new_mix.repetitions, flow_rate=new_mix.flow_rate) + + +class BlowOutVolumeError(Exception): + pass + + +# --------------------------------------------------------------------------- +# Legacy → new adapters +# --------------------------------------------------------------------------- + + +from pylabrobot.capabilities.capability import BackendParams # noqa: E402 +from pylabrobot.legacy._backend_params import _DictBackendParams # noqa: E402 + + +class _LHAdapter(_NewLHBackend): + """Adapts legacy LiquidHandlerBackend to new LiquidHandlerBackend.""" + + def __init__(self, legacy: LiquidHandlerBackend): + self._legacy = legacy + + @property + def num_channels(self) -> int: + return self._legacy.num_channels + + async def pick_up_tips( + self, + ops: List[_NewPickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [Pickup(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.pick_up_tips(ops=legacy_ops, use_channels=use_channels, **kw) + + async def drop_tips( + self, + ops: List[_NewTipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [Drop(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.drop_tips(ops=legacy_ops, use_channels=use_channels, **kw) + + async def aspirate( + self, + ops: List[_NewAspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [ + SingleChannelAspiration( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=_convert_mix(op.mix), + ) + for op in ops + ] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.aspirate(ops=legacy_ops, use_channels=use_channels, **kw) + + async def dispense( + self, + ops: List[_NewDispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [ + SingleChannelDispense( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=_convert_mix(op.mix), + ) + for op in ops + ] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.dispense(ops=legacy_ops, use_channels=use_channels, **kw) + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return self._legacy.can_pick_up_tip(channel_idx, tip) + + async def request_tip_presence(self) -> List[Optional[bool]]: + return await self._legacy.request_tip_presence() + + +class _Head96Adapter(_NewHead96Backend): + """Adapts legacy LiquidHandlerBackend to new Head96Backend.""" + + def __init__(self, legacy: LiquidHandlerBackend): + self._legacy = legacy + + async def pick_up_tips96( + self, pickup: _NewPickupTipRack, backend_params: Optional[BackendParams] = None + ): + legacy_pickup = PickupTipRack(resource=pickup.resource, offset=pickup.offset, tips=pickup.tips) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.pick_up_tips96(pickup=legacy_pickup, **kw) + + async def drop_tips96( + self, drop: _NewDropTipRack, backend_params: Optional[BackendParams] = None + ): + legacy_drop = DropTipRack(resource=drop.resource, offset=drop.offset) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.drop_tips96(drop=legacy_drop, **kw) + + async def aspirate96( + self, + aspiration: Union[_NewMHAP, _NewMHAC], + backend_params: Optional[BackendParams] = None, + ): + if isinstance(aspiration, _NewMHAP): + legacy_asp: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] = ( + MultiHeadAspirationPlate( + wells=aspiration.wells, + offset=aspiration.offset, + tips=aspiration.tips, + volume=aspiration.volume, + flow_rate=aspiration.flow_rate, + liquid_height=aspiration.liquid_height, + blow_out_air_volume=aspiration.blow_out_air_volume, + mix=_convert_mix(aspiration.mix), + ) + ) + else: + legacy_asp = MultiHeadAspirationContainer( + container=aspiration.container, + offset=aspiration.offset, + tips=aspiration.tips, + volume=aspiration.volume, + flow_rate=aspiration.flow_rate, + liquid_height=aspiration.liquid_height, + blow_out_air_volume=aspiration.blow_out_air_volume, + mix=_convert_mix(aspiration.mix), + ) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.aspirate96(aspiration=legacy_asp, **kw) + + async def dispense96( + self, + dispense: Union[_NewMHDP, _NewMHDC], + backend_params: Optional[BackendParams] = None, + ): + if isinstance(dispense, _NewMHDP): + legacy_disp: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] = ( + MultiHeadDispensePlate( + wells=dispense.wells, + offset=dispense.offset, + tips=dispense.tips, + volume=dispense.volume, + flow_rate=dispense.flow_rate, + liquid_height=dispense.liquid_height, + blow_out_air_volume=dispense.blow_out_air_volume, + mix=_convert_mix(dispense.mix), + ) + ) + else: + legacy_disp = MultiHeadDispenseContainer( + container=dispense.container, + offset=dispense.offset, + tips=dispense.tips, + volume=dispense.volume, + flow_rate=dispense.flow_rate, + liquid_height=dispense.liquid_height, + blow_out_air_volume=dispense.blow_out_air_volume, + mix=_convert_mix(dispense.mix), + ) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.dispense96(dispense=legacy_disp, **kw) + + +_LEGACY_TO_NEW_GRIP = {d: _NewGripDirection[d.name] for d in GripDirection} + + +class _ArmAdapter(OrientableGripperArmBackend): + """Adapts legacy LiquidHandlerBackend arm methods to new OrientableGripperArmBackend.""" + + def __init__(self, legacy: LiquidHandlerBackend): + self._legacy = legacy + + async def pick_up_at_location(self, location, direction, resource_width, backend_params=None): + kw = backend_params.kwargs.copy() if isinstance(backend_params, _DictBackendParams) else {} + pickup = kw.pop("_pickup") + await self._legacy.pick_up_resource(pickup=pickup, **kw) + + async def drop_at_location(self, location, direction, resource_width, backend_params=None): + kw = backend_params.kwargs.copy() if isinstance(backend_params, _DictBackendParams) else {} + drop = kw.pop("_drop") + await self._legacy.drop_resource(drop=drop, **kw) + + async def move_to_location(self, location, direction, backend_params=None): + kw = backend_params.kwargs.copy() if isinstance(backend_params, _DictBackendParams) else {} + move = kw.pop("_move") + await self._legacy.move_picked_up_resource(move=move, **kw) + + # -- stubs for abstract methods not used via legacy path ---- + + async def setup(self, backend_params=None, **kwargs): + pass + + async def stop(self): + pass + + async def halt(self, backend_params=None): + pass + + async def park(self, backend_params=None): + pass + + async def request_gripper_location(self, backend_params=None) -> CartesianPose: + raise NotImplementedError("request_gripper_location not available via legacy adapter") + + async def open_gripper(self, gripper_width, backend_params=None): + pass + + async def close_gripper(self, gripper_width, backend_params=None): + pass + + async def is_gripper_closed(self, backend_params=None) -> bool: + return False + + +# --------------------------------------------------------------------------- +# LiquidHandler +# --------------------------------------------------------------------------- + + +class LiquidHandler(Resource, Machine): + """Deprecated. Use pylabrobot.hamilton.liquid_handlers.star.star.STAR instead.""" + + def __init__( + self, + backend: LiquidHandlerBackend, + deck: Deck, + default_offset_head96: Optional[Coordinate] = None, + name: Optional[str] = None, + ): + Resource.__init__( + self, + name=name if name is not None else f"lh_{deck.name}", + size_x=deck._size_x, + size_y=deck._size_y, + size_z=deck._size_z, + category="liquid_handler", + ) + Machine.__init__(self, backend=backend) + + self.backend: LiquidHandlerBackend = backend # fix type + + self.deck = deck + + self.head: Dict[int, TipTracker] = {} + self.head96: Dict[int, TipTracker] = {} + self._default_use_channels: Optional[List[int]] = None + + # New capability instances — created during setup() + self._lh_cap: Optional[PIP] = None + self._head96_cap: Optional[Head96] = None + self._arm_cap: Optional[OrientableArm] = None + + # Default offset applied to all 96-head operations. Any offset passed to a 96-head method is + # added to this value. + self.default_offset_head96: Coordinate = default_offset_head96 or Coordinate.zero() + + # assign deck as only child resource, and set location of self to origin. + self.location = Coordinate.zero() + super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) + + self._resource_pickups: Dict[int, Optional[ResourcePickup]] = {} + + @property + def _resource_pickup(self) -> Optional[ResourcePickup]: + return self._resource_pickups.get(0) + + @_resource_pickup.setter + def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: + self._resource_pickups[0] = value + + async def setup(self, **backend_kwargs): + if self.setup_finished: + raise RuntimeError("The setup has already finished. See `LiquidHandler.stop`.") + + self.backend.set_deck(self.deck) + self.backend.set_heads(head=self.head, head96=self.head96) + await super().setup(**backend_kwargs) + + # Create capabilities with adapter backends + self._lh_cap = PIP(backend=_LHAdapter(self.backend), deck=self.deck) + await self._lh_cap._on_setup() + + if self.backend.head96_installed: + self._head96_cap = Head96( + backend=_Head96Adapter(self.backend), + deck=self.deck, + ) + await self._head96_cap._on_setup() + + # Alias head trackers from capabilities for backward compat + self.head = self._lh_cap.head + self.head96 = self._head96_cap.head if self._head96_cap is not None else {} + + self.backend.set_heads(head=self.head, head96=self.head96 or None) + + for tracker in self.head.values(): + tracker.register_callback(self._state_updated) + for tracker in self.head96.values(): + tracker.register_callback(self._state_updated) + + self._resource_pickups = {a: None for a in range(self.backend.num_arms)} + + # Create arm capability with adapter backend + if self.backend.num_arms > 0: + self._arm_cap = OrientableArm(backend=_ArmAdapter(self.backend), reference_resource=self.deck) + await self._arm_cap._on_setup() + + def serialize_state(self) -> Dict[str, Any]: + head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} + head96_state = ( + {channel: tracker.serialize() for channel, tracker in self.head96.items()} + if self.head96 + else None + ) + arm_state: Optional[Dict[int, Any]] + if self._resource_pickups: + arm_state = { + arm_id: serialize(pickup) if pickup is not None else None + for arm_id, pickup in self._resource_pickups.items() + } + else: + arm_state = None + return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} + + def load_state(self, state: Dict[str, Any]): + head_state = state["head_state"] + for channel, tracker_state in head_state.items(): + self.head[channel].load_state(tracker_state) + + head96_state = state.get("head96_state", {}) + if head96_state and self.head96: + for channel, tracker_state in head96_state.items(): + self.head96[channel].load_state(tracker_state) + + # arm_state is informational only (read via serialize_state); no load needed since + # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. + + def update_head_state(self, state: Dict[int, Optional[Tip]]): + assert set(state.keys()).issubset(set(self.head.keys())), "Invalid channel." + + for channel, tip in state.items(): + if tip is None: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + else: + if self.head[channel].has_tip: # remove tip so we can update the head. + self.head[channel].remove_tip() + self.head[channel].add_tip(tip) + + def clear_head_state(self): + self.update_head_state({c: None for c in self.head.keys()}) + + def summary(self): + print(self.deck.summary()) + + def _assert_positions_unique(self, positions: List[str]): + not_none = [p for p in positions if p is not None] + if len(not_none) != len(set(not_none)): + raise ValueError("Positions must be unique.") + + def _assert_resources_exist(self, resources: Sequence[Resource]): + for resource in resources: + # names on the deck are unique, so we can simply check if the resource matches the one on + # the deck (if any). + resource_from_deck = self.deck.get_resource(resource.name) + # it might be better to use `is`, but that would probably cause problems with autoreload. + if not resource_from_deck == resource: + raise ValueError(f"Resource {resource} is not assigned to the deck.") + + def _check_args( + self, + method: Callable, + backend_kwargs: Dict[str, Any], + default: Set[str], + strictness: Strictness, + ) -> Set[str]: + + # if method is an AsyncMock, skip the checks + if isinstance(method, unittest.mock.AsyncMock): + return set() + + default_args = default.union({"self"}) + + sig = inspect.signature(method) + args = {arg: param for arg, param in sig.parameters.items() if arg not in default_args} + vars_keyword = { + arg + for arg, param in sig.parameters.items() # **kwargs + if param.kind == inspect.Parameter.VAR_KEYWORD + } + args = { + arg: param + for arg, param in args.items() # keep only *args and **kwargs + if param.kind + not in { + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + } + } + non_default = {arg for arg, param in args.items() if param.default == inspect.Parameter.empty} + + backend_kws = set(backend_kwargs.keys()) + + missing = non_default - backend_kws + if len(missing) > 0: + raise TypeError(f"Missing arguments to backend.{method.__name__}: {missing}") + + if len(vars_keyword) > 0: + return set() # no extra arguments if the method accepts **kwargs + + extra = backend_kws - set(args.keys()) + if len(extra) > 0 and len(vars_keyword) == 0: + if strictness == Strictness.STRICT: + raise TypeError(f"Extra arguments to backend.{method.__name__}: {extra}") + elif strictness == Strictness.WARN: + warnings.warn(f"Extra arguments to backend.{method.__name__}: {extra}") + else: + pass + + return extra + + def _make_sure_channels_exist(self, channels: List[int]): + invalid_channels = [c for c in channels if c not in self.head] + if not len(invalid_channels) == 0: + raise ValueError(f"Invalid channels: {invalid_channels}") + + def _format_param(self, value: Any) -> Any: + if isinstance(value, Resource): + return value.name + try: + if isinstance(value, Sequence) and len(value) > 0 and isinstance(value[0], Resource): + return [v.name for v in value] + except Exception: + pass + return value + + def _log_command(self, name: str, **kwargs) -> None: + _ = ", ".join(f"{k}={self._format_param(v)}" for k, v in kwargs.items()) + + def get_picked_up_resource(self) -> Optional[Resource]: + if self._resource_pickup is None: + return None + return self._resource_pickup.resource + + @need_setup_finished + async def pick_up_tips( + self, + tip_spots: List[TipSpot], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + **backend_kwargs, + ): + + self._log_command( + "pick_up_tips", + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + ) + + self._assert_resources_exist(tip_spots) + + # fix the backend kwargs + extras = self._check_args( + self.backend.pick_up_tips, + backend_kwargs, + default={"ops", "use_channels"}, + strictness=get_strictness(), + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._lh_cap is not None + await self._lh_cap.pick_up_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + def get_mounted_tips(self) -> List[Optional[Tip]]: + return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] + + @need_setup_finished + async def drop_tips( + self, + tip_spots: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + allow_nonzero_volume: bool = False, + **backend_kwargs, + ): + + self._log_command( + "drop_tips", + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + ) + + self._assert_resources_exist(tip_spots) + + # fix the backend kwargs + extras = self._check_args( + self.backend.drop_tips, + backend_kwargs, + default={"ops", "use_channels"}, + strictness=get_strictness(), + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._lh_cap is not None + await self._lh_cap.drop_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + async def return_tips( + self, + use_channels: Optional[list[int]] = None, + allow_nonzero_volume: bool = False, + offsets: Optional[List[Coordinate]] = None, + **backend_kwargs, + ): + + self._log_command( + "return_tips", + use_channels=use_channels, + allow_nonzero_volume=allow_nonzero_volume, + ) + + tip_spots: List[TipSpot] = [] + channels: List[int] = [] + + for channel, tracker in self.head.items(): + if use_channels is not None and channel not in use_channels: + continue + if tracker.has_tip: + origin = tracker.get_tip_origin() + if origin is None: + raise RuntimeError("No tip origin found.") + tip_spots.append(origin) + channels.append(channel) + + if len(tip_spots) == 0: + raise RuntimeError("No tips have been picked up.") + + return await self.drop_tips( + tip_spots=tip_spots, + use_channels=channels, + allow_nonzero_volume=allow_nonzero_volume, + offsets=offsets, + **backend_kwargs, + ) + + async def discard_tips( + self, + use_channels: Optional[List[int]] = None, + allow_nonzero_volume: bool = True, + offsets: Optional[List[Coordinate]] = None, + **backend_kwargs, + ): + + self._log_command( + "discard_tips", + use_channels=use_channels, + allow_nonzero_volume=allow_nonzero_volume, + offsets=offsets, + ) + + # Different default value from drop_tips: here we factor in the tip tracking. + if use_channels is None: + use_channels = [c for c, t in self.head.items() if t.has_tip] + + n = len(use_channels) + + if n == 0: + raise RuntimeError("No tips have been picked up and no channels were specified.") + + trash = self.deck.get_trash_area() + trash_offsets = get_tight_single_resource_liquid_op_offsets( + trash, + num_channels=n, + ) + # add trash_offsets to offsets if defined, otherwise use trash_offsets + # too advanced for mypy + offsets = [ + o + to if o is not None else to + for o, to in zip(offsets or [None] * n, trash_offsets) # type: ignore + ] + + return await self.drop_tips( + tip_spots=[trash] * n, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + **backend_kwargs, + ) + + async def move_tips( + self, + source_tip_spots: List[TipSpot], + dest_tip_spots: List[TipSpot], + ): + + if len(source_tip_spots) != len(dest_tip_spots): + raise ValueError("Number of source and destination tip spots must match.") + + use_channels = list(range(len(source_tip_spots))) + + await self.pick_up_tips( + tip_spots=source_tip_spots, + use_channels=use_channels, + ) + await self.drop_tips( + tip_spots=dest_tip_spots, + use_channels=use_channels, + ) + + def _check_containers(self, resources: Sequence[Resource]): + not_containers = [r for r in resources if not isinstance(r, Container)] + if len(not_containers) > 0: + raise TypeError(f"Resources must be `Container`s, got {not_containers}") + + @need_setup_finished + async def aspirate( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, + **backend_kwargs, + ): + + self._log_command( + "aspirate", + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + ) + + # fix the backend kwargs + extras = self._check_args( + self.backend.aspirate, + backend_kwargs, + default={"ops", "use_channels"}, + strictness=get_strictness(), + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._lh_cap is not None + await self._lh_cap.aspirate( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + mix=[_NewMix(volume=m.volume, repetitions=m.repetitions, flow_rate=m.flow_rate) for m in mix] + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + @need_setup_finished + async def dispense( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, + **backend_kwargs, + ): + + self._log_command( + "dispense", + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + ) + + # fix the backend kwargs + extras = self._check_args( + self.backend.dispense, + backend_kwargs, + default={"ops", "use_channels"}, + strictness=get_strictness(), + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._lh_cap is not None + await self._lh_cap.dispense( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + mix=[_NewMix(volume=m.volume, repetitions=m.repetitions, flow_rate=m.flow_rate) for m in mix] + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + async def transfer( + self, + source: Well, + targets: List[Well], + source_vol: Optional[float] = None, + ratios: Optional[List[float]] = None, + target_vols: Optional[List[float]] = None, + aspiration_flow_rate: Optional[float] = None, + dispense_flow_rates: Optional[List[Optional[float]]] = None, + **backend_kwargs, + ): + + self._log_command( + "transfer", + source=source, + targets=targets, + source_vol=source_vol, + ratios=ratios, + target_vols=target_vols, + aspiration_flow_rate=aspiration_flow_rate, + dispense_flow_rates=dispense_flow_rates, + ) + + if target_vols is not None: + if ratios is not None: + raise TypeError("Cannot specify ratios and target_vols at the same time") + if source_vol is not None: + raise TypeError("Cannot specify source_vol and target_vols at the same time") + else: + if source_vol is None: + raise TypeError("Must specify either source_vol or target_vols") + + if ratios is None: + ratios = [1] * len(targets) + + target_vols = [source_vol * r / sum(ratios) for r in ratios] + + await self.aspirate( + resources=[source], + vols=[sum(target_vols)], + flow_rates=[aspiration_flow_rate], + **backend_kwargs, + ) + dispense_flow_rates = dispense_flow_rates or [None] * len(targets) + for target, vol, dfr in zip(targets, target_vols, dispense_flow_rates): + await self.dispense( + resources=[target], + vols=[vol], + flow_rates=[dfr], + use_channels=[0], + **backend_kwargs, + ) + + @contextlib.contextmanager + def use_channels(self, channels: List[int]): + + self._default_use_channels = channels + if self._lh_cap is not None: + self._lh_cap._default_use_channels = channels + + try: + yield + finally: + self._default_use_channels = None + if self._lh_cap is not None: + self._lh_cap._default_use_channels = None + + @contextlib.asynccontextmanager + async def use_tips( + self, + tip_spots: List[TipSpot], + channels: Optional[List[int]] = None, + discard: bool = True, + ): + + if channels is None: + channels = list(range(len(tip_spots))) + + if len(tip_spots) != len(channels): + raise ValueError("Number of tip spots and channels must match.") + + await self.pick_up_tips(tip_spots, use_channels=channels) + try: + yield + finally: + if discard: + await self.discard_tips(use_channels=channels) + else: + await self.return_tips(use_channels=channels) + + async def pick_up_tips96( + self, + tip_rack: TipRack, + offset: Coordinate = Coordinate.zero(), + **backend_kwargs, + ): + + offset = self.default_offset_head96 + offset + + self._log_command( + "pick_up_tips96", + tip_rack=tip_rack, + offset=offset, + ) + + extras = self._check_args( + self.backend.pick_up_tips96, backend_kwargs, default={"pickup"}, strictness=get_strictness() + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._head96_cap is not None + await self._head96_cap.pick_up_tips( + tip_rack=tip_rack, + offset=offset, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + async def drop_tips96( + self, + resource: Union[TipRack, Trash], + offset: Coordinate = Coordinate.zero(), + allow_nonzero_volume: bool = False, + **backend_kwargs, + ): + + offset = self.default_offset_head96 + offset + + self._log_command( + "drop_tips96", + resource=resource, + offset=offset, + allow_nonzero_volume=allow_nonzero_volume, + ) + + extras = self._check_args( + self.backend.drop_tips96, backend_kwargs, default={"drop"}, strictness=get_strictness() + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._head96_cap is not None + await self._head96_cap.drop_tips( + resource=resource, + offset=offset, + allow_nonzero_volume=allow_nonzero_volume, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + def _get_96_head_origin_tip_rack(self) -> Optional[TipRack]: + tip_spot = self.head96[0].get_tip_origin() + if tip_spot is None: + return None + tip_rack = tip_spot.parent + if tip_rack is None: + # very unlikely, but just in case + raise RuntimeError("No tip rack found for tip") + for i in range(tip_rack.num_items): + other_tip_spot = self.head96[i].get_tip_origin() + if other_tip_spot is None: + raise RuntimeError("Not all channels have a tip origin") + other_tip_rack = other_tip_spot.parent + if tip_rack != other_tip_rack: + raise RuntimeError("All tips must be from the same tip rack") + return tip_rack + + async def return_tips96( + self, + allow_nonzero_volume: bool = False, + offset: Coordinate = Coordinate.zero(), + **backend_kwargs, + ): + + self._log_command( + "return_tips96", + allow_nonzero_volume=allow_nonzero_volume, + ) + + tip_rack = self._get_96_head_origin_tip_rack() + if tip_rack is None: + raise RuntimeError("No tips have been picked up with the 96 head") + return await self.drop_tips96( + tip_rack, + allow_nonzero_volume=allow_nonzero_volume, + offset=offset, + **backend_kwargs, + ) + + async def discard_tips96(self, allow_nonzero_volume: bool = True, **backend_kwargs): + + self._log_command( + "discard_tips96", + allow_nonzero_volume=allow_nonzero_volume, + ) + + return await self.drop_tips96( + self.deck.get_trash_area96(), + allow_nonzero_volume=allow_nonzero_volume, + **backend_kwargs, + ) + + def _check_96_head_fits_in_container(self, container: Container) -> bool: + tip_width = 2 # approximation + distance_between_tips = 9 + + return ( + container.get_absolute_size_x() >= tip_width + distance_between_tips * 11 + and container.get_absolute_size_y() >= tip_width + distance_between_tips * 7 + ) + + async def aspirate96( + self, + resource: Union[Plate, Container, List[Well]], + volume: float, + offset: Coordinate = Coordinate.zero(), + flow_rate: Optional[float] = None, + liquid_height: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, + **backend_kwargs, + ): + + offset = self.default_offset_head96 + offset + + self._log_command( + "aspirate96", + resource=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + + extras = self._check_args( + self.backend.aspirate96, backend_kwargs, default={"aspiration"}, strictness=get_strictness() + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._head96_cap is not None + await self._head96_cap.aspirate( + resource=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=_NewMix(volume=mix.volume, repetitions=mix.repetitions, flow_rate=mix.flow_rate) + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + async def dispense96( + self, + resource: Union[Plate, Container, List[Well]], + volume: float, + offset: Coordinate = Coordinate.zero(), + flow_rate: Optional[float] = None, + liquid_height: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, + **backend_kwargs, + ): + + offset = self.default_offset_head96 + offset + + self._log_command( + "dispense96", + resource=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + + extras = self._check_args( + self.backend.dispense96, backend_kwargs, default={"dispense"}, strictness=get_strictness() + ) + for extra in extras: + del backend_kwargs[extra] + + assert self._head96_cap is not None + await self._head96_cap.dispense( + resource=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=_NewMix(volume=mix.volume, repetitions=mix.repetitions, flow_rate=mix.flow_rate) + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + + async def stamp( + self, + source: Plate, # TODO + target: Plate, + volume: float, + aspiration_flow_rate: Optional[float] = None, + dispense_flow_rate: Optional[float] = None, + ): + + self._log_command( + "stamp", + source=source, + target=target, + volume=volume, + aspiration_flow_rate=aspiration_flow_rate, + dispense_flow_rate=dispense_flow_rate, + ) + + assert (source.num_items_x, source.num_items_y) == ( + target.num_items_x, + target.num_items_y, + ), "Source and target plates must be the same shape" + + await self.aspirate96(resource=source, volume=volume, flow_rate=aspiration_flow_rate) + await self.dispense96(resource=target, volume=volume, flow_rate=dispense_flow_rate) + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: Optional[float] = None, + direction: GripDirection = GripDirection.FRONT, + **backend_kwargs, + ): + self._log_command( + "pick_up_resource", + resource=resource, + offset=offset, + pickup_distance_from_top=pickup_distance_from_top, + direction=direction, + ) + + if self.setup_finished and not self._resource_pickups: + raise RuntimeError("No robotic arm is installed on this liquid handler.") + + if pickup_distance_from_top is None: + if resource.preferred_pickup_location is not None: + pickup_distance_from_top = resource.get_size_z() - resource.preferred_pickup_location.z + else: + pickup_distance_from_top = 5.0 + + if self._resource_pickup is not None: + raise RuntimeError(f"Resource {self._resource_pickup.resource.name} already picked up") + + resource_pickup = ResourcePickup( + resource=resource, + offset=offset, + pickup_distance_from_top=pickup_distance_from_top, + direction=direction, + ) + + extras = self._check_args( + self.backend.pick_up_resource, backend_kwargs, default={"pickup"}, strictness=get_strictness() + ) + for extra in extras: + del backend_kwargs[extra] + + pickup_distance_from_bottom = resource.get_size_z() - pickup_distance_from_top + await self._arm_cap.pick_up_resource( + resource=resource, + offset=offset, + pickup_distance_from_bottom=pickup_distance_from_bottom, + direction=_LEGACY_TO_NEW_GRIP[direction], + backend_params=_DictBackendParams({"_pickup": resource_pickup, **backend_kwargs}), + ) + + self._resource_pickup = resource_pickup + self._state_updated() + + async def move_picked_up_resource( + self, + to: Coordinate, + offset: Coordinate = Coordinate.zero(), + direction: Optional[GripDirection] = None, + **backend_kwargs, + ): + + self._log_command( + "move_picked_up_resource", + to=to, + offset=offset, + ) + + if self._resource_pickup is None: + raise RuntimeError("No resource picked up") + + move = ResourceMove( + location=to, + resource=self._resource_pickup.resource, + gripped_direction=direction or self._resource_pickup.direction, + pickup_distance_from_top=self._resource_pickup.pickup_distance_from_top, + offset=offset, + ) + + grip_dir = _LEGACY_TO_NEW_GRIP[direction or self._resource_pickup.direction] + await self._arm_cap.move_picked_up_resource( + to=to, + direction=grip_dir, + offset=offset, + backend_params=_DictBackendParams({"_move": move, **backend_kwargs}), + ) + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + direction: GripDirection = GripDirection.FRONT, + **backend_kwargs, + ): + self._log_command( + "drop_resource", + destination=destination, + offset=offset, + direction=direction, + ) + + if self._resource_pickup is None: + raise RuntimeError("No resource picked up") + resource = self._resource_pickup.resource + + if isinstance(destination, Resource): + destination.check_can_drop_resource_here(resource) + + # compute rotation based on the pickup_direction and drop_direction + if self._resource_pickup.direction == direction: + rotation_applied_by_move = 0 + if (self._resource_pickup.direction, direction) in ( + (GripDirection.FRONT, GripDirection.RIGHT), + (GripDirection.RIGHT, GripDirection.BACK), + (GripDirection.BACK, GripDirection.LEFT), + (GripDirection.LEFT, GripDirection.FRONT), + ): + rotation_applied_by_move = 90 + if (self._resource_pickup.direction, direction) in ( + (GripDirection.FRONT, GripDirection.BACK), + (GripDirection.BACK, GripDirection.FRONT), + (GripDirection.LEFT, GripDirection.RIGHT), + (GripDirection.RIGHT, GripDirection.LEFT), + ): + rotation_applied_by_move = 180 + if (self._resource_pickup.direction, direction) in ( + (GripDirection.RIGHT, GripDirection.FRONT), + (GripDirection.BACK, GripDirection.RIGHT), + (GripDirection.LEFT, GripDirection.BACK), + (GripDirection.FRONT, GripDirection.LEFT), + ): + rotation_applied_by_move = 270 + + resource_absolute_rotation_after_move = ( + resource.get_absolute_rotation().z + rotation_applied_by_move + ) + destination_rotation = ( + destination.get_absolute_rotation().z if not isinstance(destination, Coordinate) else 0 + ) + resource_rotation_wrt_destination = resource_absolute_rotation_after_move - destination_rotation + resource_rotation_wrt_destination_wrt_local = ( + resource_rotation_wrt_destination - resource.rotation.z + ) + + # compute destination location for the legacy ResourceDrop + if isinstance(destination, ResourceStack): + assert destination.direction == "z", ( + "Only ResourceStacks with direction 'z' are currently supported" + ) + if resource_rotation_wrt_destination % 180 != 0: + raise ValueError( + "Resource rotation wrt ResourceStack must be a multiple of 180 degrees, " + f"got {resource_rotation_wrt_destination} degrees" + ) + to_location = destination.get_location_wrt(self.deck) + destination.get_new_child_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + elif isinstance(destination, Coordinate): + to_location = destination + elif isinstance(destination, ResourceHolder): + if destination.resource is not None and destination.resource is not resource: + raise RuntimeError("Destination already has a plate") + child_wrt_parent = destination.get_default_child_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + to_location = destination.get_location_wrt(self.deck) + child_wrt_parent + elif isinstance(destination, PlateAdapter): + if not isinstance(resource, Plate): + raise ValueError("Only plates can be moved to a PlateAdapter") + adjusted_plate_anchor = destination.compute_plate_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + to_location = destination.get_location_wrt(self.deck) + adjusted_plate_anchor + elif isinstance(destination, Plate) and isinstance(resource, Lid): + plate_location = destination.get_location_wrt(self.deck) + child_wrt_parent = destination.get_lid_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + to_location = plate_location + child_wrt_parent + else: + to_location = destination.get_location_wrt(self.deck) + + drop = ResourceDrop( + resource=self._resource_pickup.resource, + destination=to_location, + destination_absolute_rotation=destination.get_absolute_rotation() + if isinstance(destination, Resource) + else Rotation(0, 0, 0), + offset=offset, + pickup_distance_from_top=self._resource_pickup.pickup_distance_from_top, + pickup_direction=self._resource_pickup.direction, + direction=direction, + rotation=rotation_applied_by_move, + ) + + # Delegate to arm capability — handles backend call + resource tree update + await self._arm_cap.drop_resource( + destination=destination, + offset=offset, + direction=_LEGACY_TO_NEW_GRIP[direction], + backend_params=_DictBackendParams({"_drop": drop, **backend_kwargs}), + ) + + self._resource_pickup = None + self._state_updated() + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: float = 0, + pickup_direction: GripDirection = GripDirection.FRONT, + drop_direction: GripDirection = GripDirection.FRONT, + **backend_kwargs, + ): + + self._log_command( + "move_resource", + resource=resource, + to=to, + intermediate_locations=intermediate_locations, + pickup_offset=pickup_offset, + destination_offset=destination_offset, + pickup_distance_from_top=pickup_distance_from_top, + pickup_direction=pickup_direction, + drop_direction=drop_direction, + ) + + extra = self._check_args( + self.backend.pick_up_resource, + backend_kwargs, + default={"pickup"}, + strictness=Strictness.IGNORE, + ) + pickup_kwargs = {k: v for k, v in backend_kwargs.items() if k not in extra} + + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_top=pickup_distance_from_top, + direction=pickup_direction, + **pickup_kwargs, + ) + + for intermediate_location in intermediate_locations or []: + await self.move_picked_up_resource(to=intermediate_location) + + extra = self._check_args( + self.backend.drop_resource, + backend_kwargs, + default={"drop"}, + strictness=Strictness.IGNORE, + ) + drop_kwargs = {k: v for k, v in backend_kwargs.items() if k not in extra} + + await self.drop_resource( + destination=to, + offset=destination_offset, + direction=drop_direction, + **drop_kwargs, + ) + + async def move_lid( + self, + lid: Lid, + to: Union[Plate, ResourceStack, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_direction: GripDirection = GripDirection.FRONT, + drop_direction: GripDirection = GripDirection.FRONT, + pickup_distance_from_top: float = 5.7 - 3.33, + **backend_kwargs, + ): + + self._log_command( + "move_lid", + lid=lid, + to=to, + intermediate_locations=intermediate_locations, + pickup_offset=pickup_offset, + destination_offset=destination_offset, + pickup_direction=pickup_direction, + drop_direction=drop_direction, + pickup_distance_from_top=pickup_distance_from_top, + ) + + await self.move_resource( + lid, + to=to, + intermediate_locations=intermediate_locations, + pickup_distance_from_top=pickup_distance_from_top, + pickup_offset=pickup_offset, + destination_offset=destination_offset, + pickup_direction=pickup_direction, + drop_direction=drop_direction, + **backend_kwargs, + ) + + async def move_plate( + self, + plate: Plate, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + drop_direction: GripDirection = GripDirection.FRONT, + pickup_direction: GripDirection = GripDirection.FRONT, + pickup_distance_from_top: float = 13.2 - 3.33, + **backend_kwargs, + ): + + self._log_command( + "move_plate", + plate=plate, + to=to, + intermediate_locations=intermediate_locations, + pickup_offset=pickup_offset, + destination_offset=destination_offset, + pickup_direction=pickup_direction, + drop_direction=drop_direction, + pickup_distance_from_top=pickup_distance_from_top, + ) + + await self.move_resource( + plate, + to=to, + intermediate_locations=intermediate_locations, + pickup_distance_from_top=pickup_distance_from_top, + pickup_offset=pickup_offset, + destination_offset=destination_offset, + pickup_direction=pickup_direction, + drop_direction=drop_direction, + **backend_kwargs, + ) + + def serialize(self) -> dict: + return { + **Resource.serialize(self), + **Machine.serialize(self), + "default_offset_head96": serialize(self.default_offset_head96), + } + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler: + deck_data = data["children"][0] + deck = Deck.deserialize(data=deck_data, allow_marshal=allow_marshal) + backend = LiquidHandlerBackend.deserialize(data=data["backend"]) + + if "default_offset_head96" in data: + default_offset = deserialize(data["default_offset_head96"], allow_marshal=allow_marshal) + assert isinstance(default_offset, Coordinate) + else: + default_offset = Coordinate.zero() + + return cls( + deck=deck, + backend=backend, + default_offset_head96=default_offset, + ) + + @classmethod + def load(cls, path: str) -> LiquidHandler: + with open(path, "r", encoding="utf-8") as f: + return cls.deserialize(json.load(f)) + + async def prepare_for_manual_channel_operation(self, channel: int): + self._log_command( + "prepare_for_manual_channel_operation", + channel=channel, + ) + + assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" + await self.backend.prepare_for_manual_channel_operation(channel=channel) + + async def move_channel_x(self, channel: int, x: float): + self._log_command("move_channel_x", channel=channel, x=x) + assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" + await self.backend.move_channel_x(channel=channel, x=x) + + async def move_channel_y(self, channel: int, y: float): + self._log_command("move_channel_y", channel=channel, y=y) + assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" + await self.backend.move_channel_y(channel=channel, y=y) + + async def move_channel_z(self, channel: int, z: float): + self._log_command("move_channel_z", channel=channel, z=z) + assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" + await self.backend.move_channel_z(channel=channel, z=z) + + # -- Resource methods -- + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate], + reassign: bool = True, + ): + raise NotImplementedError( + "Cannot assign child resource to liquid handler. Use lh.deck.assign_child_resource() instead." + ) + + async def probe_tip_presence_via_pickup( + self, tip_spots: List[TipSpot], use_channels: Optional[List[int]] = None + ) -> Dict[str, bool]: + + if use_channels is None: + use_channels = list(range(len(tip_spots))) + + if len(use_channels) > self.backend.num_channels: + raise ValueError( + "Liquid handler given more channels to use than exist: " + f"Given {len(use_channels)} channels to use but liquid handler " + f"only has {self.backend.num_channels}." + ) + + if len(use_channels) != len(tip_spots): + raise ValueError( + f"Length mismatch: received {len(use_channels)} channels for " + f"{len(tip_spots)} tip spots. One channel must be assigned per tip spot." + ) + + presence_flags = [True] * len(tip_spots) + z_height = tip_spots[0].get_location_wrt(self.deck, z="top").z + 5 + + # Step 1: Cluster tip spots by x-coordinate + clusters_by_x: Dict[float, List[Tuple[TipSpot, int, int]]] = {} + for idx, tip_spot in enumerate(tip_spots): + assert tip_spot.location is not None, "TipSpot location must be at a location" + x = tip_spot.location.x + clusters_by_x.setdefault(x, []).append((tip_spot, use_channels[idx], idx)) + + sorted_clusters = [clusters_by_x[x] for x in sorted(clusters_by_x)] + + # Step 2: Probe each cluster + for cluster in sorted_clusters: + tip_subset, channel_subset, index_subset = zip(*cluster) + + try: + await self.pick_up_tips( + list(tip_subset), + use_channels=list(channel_subset), + minimum_traverse_height_at_beginning_of_a_command=z_height, + z_position_at_end_of_a_command=z_height, + ) + except ChannelizedError as e: + for ch in e.errors: + if ch in channel_subset: + failed_local_idx = channel_subset.index(ch) + presence_flags[index_subset[failed_local_idx]] = False + else: + raise + + # Step 3: Drop tips immediately after probing + if any(presence_flags[index] for index in index_subset): + spots = [ts for ts, _, i in cluster if presence_flags[i]] + use_channels = [uc for _, uc, i in cluster if presence_flags[i]] + try: + await self.drop_tips( + spots, + use_channels=use_channels, + # minimum_traverse_height_at_beginning_of_a_command=z_height, + z_position_at_end_of_a_command=z_height, + ) + except Exception as e: + assert cluster[0][0].location is not None, "TipSpot location must be at a location" + print(f"Warning: drop_tips failed for cluster at x={cluster[0][0].location.x}: {e}") + + return {ts.name: flag for ts, flag in zip(tip_spots, presence_flags)} + + async def probe_tip_inventory( + self, + tip_spots: List[TipSpot], + probing_fn: Optional[TipPresenceProbingMethod] = None, + use_channels: Optional[List[int]] = None, + ) -> Dict[str, bool]: + + if probing_fn is None: + probing_fn = self.probe_tip_presence_via_pickup + + results: Dict[str, bool] = {} + + if use_channels is None: + use_channels = list(range(self.backend.num_channels)) + num_channels = len(use_channels) + + for i in range(0, len(tip_spots), num_channels): + subset = tip_spots[i : i + num_channels] + use_channels = use_channels[: len(subset)] + batch_result = await probing_fn(subset, use_channels) + results.update(batch_result) + + return results + + async def consolidate_tip_inventory( + self, tip_racks: List[TipRack], use_channels: Optional[List[int]] = None + ): + + def merge_sublists(lists: List[List[TipSpot]], max_len: int) -> List[List[TipSpot]]: + merged: List[List[TipSpot]] = [] + buffer: List[TipSpot] = [] + + for sublist in lists: + if len(sublist) == 0: + continue # skip empty sublists + + if len(buffer) + len(sublist) <= max_len: + buffer.extend(sublist) + else: + if buffer: + merged.append(buffer) + buffer = sublist # start new buffer + + if len(buffer) > 0: + merged.append(buffer) + + return merged + + def divide_list_into_chunks( + list_l: List[TipSpot], chunk_size: int + ) -> Generator[List[TipSpot], None, None]: + for i in range(0, len(list_l), chunk_size): + yield list_l[i : i + chunk_size] + + clusters_by_model: Dict[int, List[Tuple[TipRack, int]]] = {} + + for idx, tip_rack in enumerate(tip_racks): + # Only consider partially-filled tip_racks + tip_status = [tip_spot.tracker.has_tip for tip_spot in tip_rack.get_all_items()] + + if not (any(tip_status) and not all(tip_status)): + continue # ignore non-partially-filled tip_racks + + tipspots_w_tips = [ + tip_spot for has_tip, tip_spot in zip(tip_status, tip_rack.get_all_items()) if has_tip + ] + + # Identify model by hashed unique physical characteristics + current_model = hash(tipspots_w_tips[0].tracker.get_tip()) + if not all( + hash(tip_spot.tracker.get_tip()) == current_model for tip_spot in tipspots_w_tips[1:] + ): + raise ValueError( + f"Tip rack {tip_rack.name} has mixed tip models, cannot consolidate: " + f"{[tip_spot.tracker.get_tip() for tip_spot in tipspots_w_tips]}" + ) + + num_empty_tipspots = len(tip_status) - len(tipspots_w_tips) + clusters_by_model.setdefault(current_model, []).append((tip_rack, num_empty_tipspots)) + + # Sort partially-filled tipracks from most to least empty + for model, rack_list in clusters_by_model.items(): + rack_list.sort(key=lambda x: x[1]) + + # Consolidate one tip model at a time across all tip_racks of that model + for model, rack_list in clusters_by_model.items(): + print(f"Consolidating: - {', '.join([rack.name for rack, _ in rack_list])}") + + all_tip_spots_list = [ + tip_spot for tip_rack, _ in rack_list for tip_spot in tip_rack.get_all_items() + ] + + # 1: Record current tip state + current_tip_presence_list = [tip_spot.has_tip() for tip_spot in all_tip_spots_list] + + # 2: Generate target/consolidated tip state + total_length = len(all_tip_spots_list) + num_tips_per_model = sum(current_tip_presence_list) + + target_tip_presence_list = [i < num_tips_per_model for i in range(total_length)] + + # 3: Calculate tip_spots involved in tip movement + tip_movement_list = [ + c - t for c, t in zip(current_tip_presence_list, target_tip_presence_list) + ] + + tip_origin_indices = [i for i, v in enumerate(tip_movement_list) if v == 1] + all_origin_tip_spots = [all_tip_spots_list[idx] for idx in tip_origin_indices] + + tip_target_indices = [i for i, v in enumerate(tip_movement_list) if v == -1] + all_target_tip_spots = [all_tip_spots_list[idx] for idx in tip_target_indices] + + # Only continue if tip_racks are not already consolidated + if len(all_target_tip_spots) == 0: + print("Tips already optimally consolidated!") + continue + + # 4: Cluster target tip_spots by BOTH parent tip_rack & x-coordinate + def key_for_tip_spot(tip_spot: TipSpot) -> Tuple[str, float]: + assert tip_spot.parent is not None and tip_spot.location is not None + return (tip_spot.parent.name, round(tip_spot.location.x, 3)) + + sorted_tip_spots = sorted(all_target_tip_spots, key=key_for_tip_spot) + + target_tip_clusters_by_parent_x: Dict[Tuple[str, float], List[TipSpot]] = {} + + for tip_spot in sorted_tip_spots: + key = key_for_tip_spot(tip_spot) + if key not in target_tip_clusters_by_parent_x: + target_tip_clusters_by_parent_x[key] = [] + target_tip_clusters_by_parent_x[key].append(tip_spot) + + current_tip_model = all_origin_tip_spots[0].tracker.get_tip() + + # Ensure there are channels that can pick up the tip model + if use_channels is None: + num_channels_available = len( + [ + c + for c in range(self.backend.num_channels) + if self.backend.can_pick_up_tip(c, current_tip_model) + ] + ) + use_channels = list(range(num_channels_available)) + num_channels_available = len(use_channels) + + # 5: Optimize speed + if num_channels_available == 0: + raise ValueError(f"No channel capable of handling tips on deck: {current_tip_model}") + + # by aggregating drop columns i.e. same drop column should not be visited twice! + if num_channels_available >= 8: # physical constraint of tip_rack's having 8 rows + merged_target_tip_clusters = merge_sublists( + list(target_tip_clusters_by_parent_x.values()), max_len=8 + ) + else: # by chunking drop tip_spots list into size of available channels + merged_target_tip_clusters = list( + divide_list_into_chunks(all_target_tip_spots, chunk_size=num_channels_available) + ) + + len_transfers = len(merged_target_tip_clusters) + + # 6: Execute tip movement/consolidation + for idx, target_tip_spots in enumerate(merged_target_tip_clusters): + print(f" - tip transfer cycle: {idx + 1} / {len_transfers}") + + origin_tip_spots = [all_origin_tip_spots.pop(0) for _ in range(len(target_tip_spots))] + + these_channels = use_channels[: len(target_tip_spots)] + await self.pick_up_tips(origin_tip_spots, use_channels=these_channels) + await self.drop_tips(target_tip_spots, use_channels=these_channels) 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 d27eba719e2..631bc4869a6 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, ) @@ -113,10 +113,11 @@ def _make_disp( class TestLiquidHandlerLayout(unittest.IsolatedAsyncioTestCase): - def setUp(self): + async def asyncSetUp(self): self.backend = _create_mock_backend(num_channels=8) self.deck = STARLetDeck() self.lh = LiquidHandler(self.backend, deck=self.deck) + await self.lh.setup() def test_resource_assignment(self): tip_car = TIP_CAR_480_A00(name="tip_carrier") diff --git a/pylabrobot/legacy/liquid_handling/standard.py b/pylabrobot/legacy/liquid_handling/standard.py new file mode 100644 index 00000000000..bc0e9580a50 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/standard.py @@ -0,0 +1,169 @@ +"""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: + volume: float + repetitions: int + flow_rate: float + + +@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/liquid_handling/utils.py b/pylabrobot/legacy/liquid_handling/utils.py new file mode 100644 index 00000000000..a59ed6e4e2d --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/utils.py @@ -0,0 +1,75 @@ +from typing import List + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS = 9 +MIN_SPACING_BETWEEN_CHANNELS = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS +# minimum spacing between the edge of the container and the center of channel +MIN_SPACING_EDGE = 1.0 + + +def _get_centers_with_margin(dim_size: float, n: int, margin: float, min_spacing: float): + """Get the centers of the channels with a minimum margin on the edges.""" + if dim_size < margin * 2 + (n - 1) * min_spacing: + raise ValueError("Resource is too small to space channels.") + if dim_size - (n - 1) * min_spacing <= min_spacing * 2: + remaining_space = dim_size - (n - 1) * min_spacing - margin * 2 + return [margin + remaining_space / 2 + i * min_spacing for i in range(n)] + return [(i + 1) * dim_size / (n + 1) for i in range(n)] + + +def get_wide_single_resource_liquid_op_offsets( + resource: Resource, + num_channels: int, + min_spacing: float = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) -> List[Coordinate]: + resource_size = resource.get_absolute_size_y() + centers = list( + reversed( + _get_centers_with_margin( + dim_size=resource_size, + n=num_channels, + margin=MIN_SPACING_EDGE, + min_spacing=min_spacing, + ) + ) + ) # reverse because channels are from back to front + + # offsets are relative to the center of the resource, but above we computed them wrt lfb + # so we need to subtract the center of the resource + # also, offsets are in absolute space, so we need to rotate the center + return [ + Coordinate( + x=0, + y=c - resource.center().rotated(resource.get_absolute_rotation()).y, + z=0, + ) + for c in centers + ] + + +def get_tight_single_resource_liquid_op_offsets( + resource: Resource, + num_channels: int, + min_spacing: float = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) -> List[Coordinate]: + channel_space = (num_channels - 1) * min_spacing + + min_y = (resource.get_absolute_size_y() - channel_space) / 2 + if min_y < MIN_SPACING_EDGE: + raise ValueError("Resource is too small to space channels.") + + centers = [min_y + i * min_spacing for i in range(num_channels)][::-1] + + # offsets are relative to the center of the resource, but above we computed them wrt lfb + # so we need to subtract the center of the resource + # also, offsets are in absolute space, so we need to rotate the center + return [ + Coordinate( + x=0, + y=c - resource.center().rotated(resource.get_absolute_rotation()).y, + z=0, + ) + for c in centers + ] diff --git a/pylabrobot/legacy/machines/__init__.py b/pylabrobot/legacy/machines/__init__.py new file mode 100644 index 00000000000..d30dcd873cf --- /dev/null +++ b/pylabrobot/legacy/machines/__init__.py @@ -0,0 +1,2 @@ +from .backend import MachineBackend +from .machine import Machine, need_setup_finished diff --git a/pylabrobot/machines/backend.py b/pylabrobot/legacy/machines/backend.py similarity index 97% rename from pylabrobot/machines/backend.py rename to pylabrobot/legacy/machines/backend.py index 1dab93ce246..686cd5bbebd 100644 --- a/pylabrobot/machines/backend.py +++ b/pylabrobot/legacy/machines/backend.py @@ -27,6 +27,7 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict): + data = data.copy() class_name = data.pop("type") subclass = find_subclass(class_name, cls=cls) if subclass is None: diff --git a/pylabrobot/machines/machine.py b/pylabrobot/legacy/machines/machine.py similarity index 77% rename from pylabrobot/machines/machine.py rename to pylabrobot/legacy/machines/machine.py index d241d8809a1..569b6741ce8 100644 --- a/pylabrobot/machines/machine.py +++ b/pylabrobot/legacy/machines/machine.py @@ -3,9 +3,9 @@ import functools import sys from abc import ABC -from typing import Any, Awaitable, Callable, TypeVar +from typing import Any, Awaitable, Callable, List, 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): @@ -42,15 +42,16 @@ class Machine(SerializableMixin, ABC): """Abstract base class for machine frontends.""" def __init__(self, backend: MachineBackend): - self.backend = backend + self._backend = backend self._setup_finished = False + self._capabilities: List[Any] = [] @property def setup_finished(self) -> bool: return self._setup_finished def serialize(self) -> dict: - return {"backend": self.backend.serialize()} + return {"backend": self._backend.serialize()} @classmethod def deserialize(cls, data: dict): @@ -61,12 +62,17 @@ def deserialize(cls, data: dict): return cls(**data_copy) async def setup(self, **backend_kwargs): - await self.backend.setup(**backend_kwargs) + await self._backend.setup(**backend_kwargs) + for cap in self._capabilities: + await cap._on_setup() self._setup_finished = True - @need_setup_finished async def stop(self): - await self.backend.stop() + if not self._setup_finished: + return + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._backend.stop() self._setup_finished = False async def __aenter__(self): diff --git a/pylabrobot/machines/machine_tests.py b/pylabrobot/legacy/machines/machine_tests.py similarity index 87% rename from pylabrobot/machines/machine_tests.py rename to pylabrobot/legacy/machines/machine_tests.py index bdfbb2d506e..5c8dbbde2d2 100644 --- a/pylabrobot/machines/machine_tests.py +++ b/pylabrobot/legacy/machines/machine_tests.py @@ -1,7 +1,8 @@ import unittest import unittest.mock -from pylabrobot.machines.machine import Machine, MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.machines.machine import Machine class TestMachine(unittest.TestCase): diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py new file mode 100644 index 00000000000..0dac974e55d --- /dev/null +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py @@ -0,0 +1,130 @@ +"""Legacy. Use pylabrobot.molecular_devices.PicoDriver instead.""" + +from typing import Dict, List, Optional + +from pylabrobot.capabilities.microscopy import ( + ImagingMode as NewImagingMode, +) +from pylabrobot.capabilities.microscopy import ( + ImagingResult as NewImagingResult, +) +from pylabrobot.capabilities.microscopy import ( + Objective as NewObjective, +) +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.molecular_devices.imageXpress.pico.backend import PicoDriver, PicoMicroscopyBackend +from pylabrobot.resources.plate import Plate + + +def _legacy_to_new_imaging_mode(mode: ImagingMode) -> NewImagingMode: + return NewImagingMode[mode.name] + + +def _legacy_to_new_objective(obj: Objective) -> NewObjective: + return NewObjective[obj.name] + + +def _new_to_legacy_imaging_result(result: NewImagingResult) -> ImagingResult: + return ImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) + + +class ExperimentalPicoBackend(ImagerBackend): + """Legacy. Use pylabrobot.molecular_devices.PicoDriver instead.""" + + 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__() + new_objectives = {pos: _legacy_to_new_objective(obj) for pos, obj in (objectives or {}).items()} + new_filter_cubes = { + pos: _legacy_to_new_imaging_mode(mode) for pos, mode in (filter_cubes or {}).items() + } + self.driver = PicoDriver( + host=host, + port=port, + lock_timeout=lock_timeout, + ) + self._microscopy = PicoMicroscopyBackend( + self.driver, + objectives=new_objectives, + filter_cubes=new_filter_cubes, + ) + + @property + def door_open(self) -> bool: + return self.driver.door_open + + async def setup(self) -> None: + await self.driver.setup() + await self._microscopy._on_setup() + + async def stop(self) -> None: + await self._microscopy._on_stop() + await self.driver.stop() + + async def get_configuration(self) -> dict: + return await self.driver.request_configuration() + + async def open_door(self) -> None: + await self.driver.open_door() + + async def close_door(self) -> None: + await self.driver.close_door() + + async def enter_objective_maintenance(self, position: int) -> None: + await self._microscopy.enter_objective_maintenance(position) + + async def exit_objective_maintenance(self) -> None: + await self._microscopy.exit_objective_maintenance() + + async def get_available_objectives(self, position: int) -> List[dict]: + return await self._microscopy.request_available_objectives(position) + + async def get_available_filter_cubes(self) -> List[dict]: + return await self._microscopy.request_available_filter_cubes() + + async def change_objective(self, position: int, objective_id: str) -> None: + await self._microscopy.change_objective(position, objective_id) + + async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: + await self._microscopy.change_filter_cube(position, filter_cube_id) + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + ) -> ImagingResult: + result = await self._microscopy.capture( + row=row, + column=column, + mode=_legacy_to_new_imaging_mode(mode), + objective=_legacy_to_new_objective(objective), + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + ) + return _new_to_legacy_imaging_result(result) diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py similarity index 92% rename from pylabrobot/microscopes/molecular_devices/pico/backend_tests.py rename to pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py index 8e8c123ea65..a3c6732d329 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py @@ -1,4 +1,4 @@ -"""Tests for PicoBackend. +"""Tests for PicoDriver. Focus: verify the gRPC commands generated and responses decoded for each high-level method. The mock channel records every (path, request, metadata) @@ -21,6 +21,7 @@ import numpy as np # type: ignore[import-not-found] +from pylabrobot.capabilities.microscopy import ImagingMode, Objective from pylabrobot.io.sila.grpc import ( decode_fields, get_field_bytes, @@ -28,7 +29,7 @@ sila_string, varint_field, ) -from pylabrobot.microscopes.molecular_devices.pico.backend import ( +from pylabrobot.molecular_devices.imageXpress.pico.backend import ( _FC_SVC, _HW_SVC, _INST_SVC, @@ -36,12 +37,12 @@ _LOCK_SVC, _OBJ_SVC, _SNAP_SVC, - ExperimentalPicoBackend, + PicoDriver, + PicoMicroscopyBackend, _decode_intermediate_response, _extract_image_buffer, _get_image_info, ) -from pylabrobot.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 @@ -180,20 +181,23 @@ def _make_backend( objectives=None, filter_cubes=None, lock_timeout=3600, -) -> Tuple[ExperimentalPicoBackend, _MockChannel]: - """Create a PicoBackend with a mock channel, bypassing setup().""" - backend = ExperimentalPicoBackend( +) -> Tuple[PicoDriver, PicoMicroscopyBackend, _MockChannel]: + """Create a PicoDriver + PicoMicroscopyBackend with a mock channel, bypassing setup().""" + driver = PicoDriver( host="127.0.0.1", port=8091, lock_timeout=lock_timeout, + ) + microscopy = PicoMicroscopyBackend( + driver=driver, objectives=objectives or {}, filter_cubes=filter_cubes or {}, ) channel = _MockChannel() - backend._channel = channel - backend._lock_id = "pylabrobot" - backend._locked = True - return backend, channel + driver._channel = channel + driver._lock_id = "pylabrobot" + driver._locked = True + return driver, microscopy, channel def _decode_sila_string_from_request(data: bytes) -> str: @@ -223,7 +227,8 @@ def _unwrap_sila_string(data: bytes) -> str: class TestSetup(unittest.IsolatedAsyncioTestCase): async def test_setup_sends_correct_sequence(self): """setup() with no objectives/filter_cubes: unlock stale, lock, query hardware.""" - backend = ExperimentalPicoBackend(host="127.0.0.1", lock_timeout=120) + driver = PicoDriver(host="127.0.0.1", lock_timeout=120) + microscopy = PicoMicroscopyBackend(driver=driver) channel = _MockChannel() channel.set_response(f"/{_LOCK_SVC}/UnlockServer", b"") @@ -238,7 +243,8 @@ async def test_setup_sends_correct_sequence(self): ) with patch("grpc.insecure_channel", return_value=channel): - await backend.setup() + await driver.setup() + await microscopy._on_setup() self.assertEqual(len(channel.calls), 4) self.assertEqual(channel.calls[0].path, f"/{_LOCK_SVC}/UnlockServer") @@ -254,8 +260,9 @@ async def test_setup_sends_correct_sequence(self): async def test_setup_configures_objectives_and_filter_cubes(self): """When objectives/filter_cubes are specified, setup() calls ChangeHardware.""" - backend = ExperimentalPicoBackend( - host="127.0.0.1", + driver = PicoDriver(host="127.0.0.1") + microscopy = PicoMicroscopyBackend( + driver=driver, objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -271,7 +278,7 @@ async def test_setup_configures_objectives_and_filter_cubes(self): f"/{_FC_SVC}/Get_InstalledFilterCubes", _sila_string_response(json.dumps({"filterCubesData": [{"Id": "DAPI"}]})), ) - # get_available_objectives / get_available_filter_cubes for validation + # request_available_objectives / request_available_filter_cubes for validation channel.set_response( f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", _sila_string_response(json.dumps({"objectives": [{"Id": "PL FLUOTAR 4x/0.13"}]})), @@ -284,7 +291,8 @@ async def test_setup_configures_objectives_and_filter_cubes(self): channel.set_response(f"/{_FC_SVC}/ChangeHardware", b"") with patch("grpc.insecure_channel", return_value=channel): - await backend.setup() + await driver.setup() + await microscopy._on_setup() # Verify ChangeHardware was called with correct JSON params obj_change_calls = channel.get_calls(f"/{_OBJ_SVC}/ChangeHardware") @@ -307,7 +315,7 @@ async def test_setup_configures_objectives_and_filter_cubes(self): class TestStop(unittest.IsolatedAsyncioTestCase): async def test_stop_sends_unlock(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() await backend.stop() @@ -324,7 +332,7 @@ async def test_stop_sends_unlock(self): class TestDoorCommands(unittest.IsolatedAsyncioTestCase): async def test_open_door(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() channel.set_response(f"/{_INST_SVC}/Initialize", b"") channel.set_response(f"/{_HW_SVC}/OpenPlateDrawer", b"") @@ -338,7 +346,7 @@ async def test_open_door(self): self.assertTrue(backend.door_open) async def test_close_door(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() backend._door_open = True channel.set_response(f"/{_INST_SVC}/Initialize", b"") channel.set_response(f"/{_HW_SVC}/ClosePlateDrawer", b"") @@ -360,11 +368,11 @@ async def test_close_door(self): class TestObjectiveMaintenanceCommands(unittest.IsolatedAsyncioTestCase): async def test_enter_maintenance(self): - backend, channel = _make_backend() + _, microscopy, 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) + await microscopy.enter_objective_maintenance(2) self.assertEqual(len(channel.calls), 2) self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Initialize") @@ -374,10 +382,10 @@ async def test_enter_maintenance(self): self.assertEqual(params, {"Index": 2}) async def test_exit_maintenance(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() channel.set_response(f"/{_OBJ_SVC}/ExitObjectiveMaintenance", b"") - await backend.exit_objective_maintenance() + await microscopy.exit_objective_maintenance() self.assertEqual(len(channel.calls), 1) self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/ExitObjectiveMaintenance") @@ -386,13 +394,13 @@ async def test_exit_maintenance(self): # --------------------------------------------------------------------------- -# Tests: get_configuration command + response decoding +# Tests: request_configuration command + response decoding # --------------------------------------------------------------------------- class TestGetConfiguration(unittest.IsolatedAsyncioTestCase): async def test_decodes_instrument_configuration(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() config = { "InstrumentConfiguration": { "objectivesComponent": {"objectives": [{"Id": "4x", "Magnification": 4}]}, @@ -404,12 +412,12 @@ async def test_decodes_instrument_configuration(self): _sila_string_response(json.dumps(config)), ) - result = await backend.get_configuration() + 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 + # Response unwrapped from SiLA String -> JSON -> InstrumentConfiguration key extracted self.assertEqual(result, config["InstrumentConfiguration"]) @@ -420,7 +428,7 @@ async def test_decodes_instrument_configuration(self): class TestChangeHardwareCommands(unittest.IsolatedAsyncioTestCase): async def test_change_objective(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() available = [{"Id": "PL FLUOTAR 4x/0.13"}, {"Id": "PL FLUOTAR 10x/0.30"}] channel.set_response( f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", @@ -428,7 +436,7 @@ async def test_change_objective(self): ) channel.set_response(f"/{_OBJ_SVC}/ChangeHardware", b"") - await backend.change_objective(1, "PL FLUOTAR 10x/0.30") + await microscopy.change_objective(1, "PL FLUOTAR 10x/0.30") # Query available, then change self.assertEqual(len(channel.calls), 2) @@ -443,14 +451,14 @@ async def test_change_objective(self): self.assertTrue(channel.calls[1].has_lock_metadata) async def test_change_objective_rejects_invalid_id(self): - backend, channel = _make_backend() + _, microscopy, 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") + await microscopy.change_objective(0, "INVALID") self.assertIn("not compatible", str(ctx.exception)) # Only the query was sent, ChangeHardware was NOT called @@ -458,14 +466,14 @@ async def test_change_objective_rejects_invalid_id(self): self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition") async def test_change_filter_cube(self): - backend, channel = _make_backend() + _, microscopy, 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") + await microscopy.change_filter_cube(1, "FITC") self.assertEqual(len(channel.calls), 2) self.assertEqual(channel.calls[0].path, f"/{_FC_SVC}/Get_CompatibleFilterCubes") @@ -504,7 +512,7 @@ def _setup_capture_channel( async def test_capture_sends_correct_snap_params(self): """Verify SnapImages request contains the right labware + snap JSON.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -519,7 +527,7 @@ async def test_capture_sends_correct_snap_params(self): } self._setup_capture_channel(channel, "uuid-1", snap_event) - await backend.capture( + await microscopy.capture( row=3, column=7, mode=ImagingMode.DAPI, @@ -576,7 +584,7 @@ async def test_capture_sends_correct_snap_params(self): async def test_capture_auto_exposure_and_autofocus(self): """When exposure_time='auto' and focal_height='auto', verify params.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -591,7 +599,7 @@ async def test_capture_auto_exposure_and_autofocus(self): } self._setup_capture_channel(channel, "uuid-2", snap_event) - await backend.capture( + await microscopy.capture( row=0, column=0, mode=ImagingMode.DAPI, @@ -615,7 +623,7 @@ async def test_capture_auto_exposure_and_autofocus(self): async def test_capture_observable_command_flow(self): """Verify the 3-step observable command protocol: start, stream, result.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -630,7 +638,7 @@ async def test_capture_observable_command_flow(self): } self._setup_capture_channel(channel, "exec-uuid-abc", snap_event) - result = await backend.capture( + result = await microscopy.capture( row=0, column=0, mode=ImagingMode.DAPI, @@ -666,7 +674,7 @@ async def test_capture_observable_command_flow(self): async def test_capture_multi_chunk_reassembly(self): """Verify image data is correctly reassembled from multiple chunks.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -703,7 +711,7 @@ async def test_capture_multi_chunk_reassembly(self): ], ) - result = await backend.capture( + result = await microscopy.capture( row=0, column=0, mode=ImagingMode.DAPI, @@ -720,7 +728,7 @@ async def test_capture_multi_chunk_reassembly(self): async def test_capture_brightfield_uses_correct_illumination(self): """Brightfield mode uses different light_channel/excitation_source.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.BRIGHTFIELD}, ) @@ -735,7 +743,7 @@ async def test_capture_brightfield_uses_correct_illumination(self): } self._setup_capture_channel(channel, "uuid-bf", snap_event) - await backend.capture( + await microscopy.capture( row=0, column=0, mode=ImagingMode.BRIGHTFIELD, 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 54% rename from pylabrobot/only_fans/backend.py rename to pylabrobot/legacy/only_fans/backend.py index 7be5c212785..87085e82f6e 100644 --- a/pylabrobot/only_fans/backend.py +++ b/pylabrobot/legacy/only_fans/backend.py @@ -1,23 +1,23 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class FanBackend(MachineBackend, metaclass=ABCMeta): - """Abstract base class for fan backends.""" + """Legacy. Use pylabrobot.capabilities.fan_control.FanBackend instead.""" @abstractmethod async def setup(self) -> None: - """Set up the fan. This should be called before any other methods.""" + """Set up the fan.""" @abstractmethod async def turn_on(self, intensity: int) -> None: - """Run the fan at intensity: integer percent between 0 and 100""" + """Run the fan at intensity: integer percent between 0 and 100.""" @abstractmethod async def turn_off(self) -> None: - """Stop the fan, but don't close the connection.""" + """Stop the fan.""" @abstractmethod async def stop(self) -> None: - """Close all connections to the fan and make sure setup() can be called again.""" + """Close all connections to the fan.""" diff --git a/pylabrobot/only_fans/chatterbox.py b/pylabrobot/legacy/only_fans/chatterbox.py similarity index 63% rename from pylabrobot/only_fans/chatterbox.py rename to pylabrobot/legacy/only_fans/chatterbox.py index 25104bb4822..38404154c08 100644 --- a/pylabrobot/only_fans/chatterbox.py +++ b/pylabrobot/legacy/only_fans/chatterbox.py @@ -1,8 +1,10 @@ -from pylabrobot.only_fans import FanBackend +"""Legacy. Use pylabrobot.hamilton.hepa_fan.HamiltonHepaFanChatterboxBackend instead.""" + +from pylabrobot.legacy.only_fans.backend import FanBackend class FanChatterboxBackend(FanBackend): - """Chatter box backend for device-free testing. Prints out all operations.""" + """Legacy chatterbox backend for device-free testing.""" async def setup(self) -> None: print("Setting up the fan.") diff --git a/pylabrobot/legacy/only_fans/fan.py b/pylabrobot/legacy/only_fans/fan.py new file mode 100644 index 00000000000..95e157aea40 --- /dev/null +++ b/pylabrobot/legacy/only_fans/fan.py @@ -0,0 +1,47 @@ +"""Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" + +from pylabrobot.capabilities.fan_control import Fan as _NewFan +from pylabrobot.capabilities.fan_control import FanBackend as _NewFanBackend +from pylabrobot.legacy.machines.machine import Machine + +from .backend import FanBackend + + +class _FanAdapter(_NewFanBackend): + def __init__(self, legacy: FanBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + async def turn_on(self, intensity: int) -> None: + await self._legacy.turn_on(intensity) + + async def turn_off(self) -> None: + await self._legacy.turn_off() + + +class Fan(Machine): + """Legacy. Use a vendor-specific machine class instead.""" + + def __init__(self, backend: FanBackend): + super().__init__(backend=backend) + self._backend: FanBackend = backend + self._cap = _NewFan(backend=_FanAdapter(backend)) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._cap._on_setup() + + async def turn_on(self, intensity: int, duration=None): + await self._cap.turn_on(intensity=intensity, duration=duration) + + async def turn_off(self): + await self._cap.turn_off() + + async def stop(self): + await self._cap._on_stop() + await super().stop() diff --git a/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py new file mode 100644 index 00000000000..de3e104ad43 --- /dev/null +++ b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py @@ -0,0 +1,33 @@ +"""Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" + +from pylabrobot.hamilton.only_fans.backend import HamiltonHepaFanDriver, HamiltonHepaFanFanBackend +from pylabrobot.legacy.only_fans.backend import FanBackend + + +class HamiltonHepaFanBackend(FanBackend): + """Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" + + def __init__(self, device_id=None): + self.driver = HamiltonHepaFanDriver(device_id=device_id) + self._fan = HamiltonHepaFanFanBackend(self.driver) + + async def setup(self) -> None: + await self.driver.setup() + + async def turn_on(self, intensity: int) -> None: + await self._fan.turn_on(intensity=intensity) + + async def turn_off(self) -> None: + await self._fan.turn_off() + + async def stop(self) -> None: + await self.driver.stop() + + +class HamiltonHepaFan: + """Deprecated. Use HamiltonHepaFanBackend instead.""" + + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`HamiltonHepaFan` is deprecated. Please use `HamiltonHepaFanBackend` instead." + ) 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 66% rename from pylabrobot/peeling/backend.py rename to pylabrobot/legacy/peeling/backend.py index 6d375205e44..44fb2fd8486 100644 --- a/pylabrobot/peeling/backend.py +++ b/pylabrobot/legacy/peeling/backend.py @@ -1,10 +1,10 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class PeelerBackend(MachineBackend, metaclass=ABCMeta): - """Backend for a peeler machine""" + """Legacy. Use pylabrobot.capabilities.peeling.PeelerBackend instead.""" @abstractmethod async def peel(self): diff --git a/pylabrobot/legacy/peeling/peeler.py b/pylabrobot/legacy/peeling/peeler.py new file mode 100644 index 00000000000..ac84a2f2b70 --- /dev/null +++ b/pylabrobot/legacy/peeling/peeler.py @@ -0,0 +1,19 @@ +"""Legacy. Use pylabrobot.azenta.XPeel instead.""" + +from pylabrobot.legacy.machines import Machine + +from .backend import PeelerBackend + + +class Peeler(Machine): + """Legacy. Use pylabrobot.azenta.XPeel instead.""" + + def __init__(self, backend: PeelerBackend): + super().__init__(backend=backend) + self._backend: PeelerBackend = backend + + async def peel(self, **backend_kwargs): + return await self._backend.peel(**backend_kwargs) + + async def restart(self, **backend_kwargs): + return await self._backend.restart(**backend_kwargs) diff --git a/pylabrobot/legacy/peeling/xpeel.py b/pylabrobot/legacy/peeling/xpeel.py new file mode 100644 index 00000000000..1ddc8a851f8 --- /dev/null +++ b/pylabrobot/legacy/peeling/xpeel.py @@ -0,0 +1,11 @@ +"""Legacy. Use pylabrobot.azenta.XPeel instead.""" + +from pylabrobot.legacy.peeling.peeler import Peeler +from pylabrobot.legacy.peeling.xpeel_backend import XPeelBackend + + +def xpeel(port: str) -> Peeler: + """Legacy. Use pylabrobot.azenta.XPeel instead.""" + return Peeler( + backend=XPeelBackend(port=port), + ) diff --git a/pylabrobot/legacy/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py new file mode 100644 index 00000000000..28f4de07cc6 --- /dev/null +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -0,0 +1,72 @@ +"""Legacy. Use pylabrobot.azenta.XPeelDriver and XPeelPeelerBackend instead.""" + +from pylabrobot.azenta.xpeel import XPeelDriver, XPeelPeelerBackend +from pylabrobot.legacy.peeling.backend import PeelerBackend + + +class XPeelBackend(PeelerBackend): + """Legacy. Use pylabrobot.azenta.XPeelDriver and XPeelPeelerBackend instead.""" + + def __init__(self, port: str, timeout=None): + self.driver = XPeelDriver(port=port, timeout=timeout) + self._peeler = XPeelPeelerBackend(self.driver) + + async def setup(self): + await self.driver.setup() + await self._peeler._on_setup() + + async def stop(self): + await self._peeler._on_stop() + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def peel(self, **kwargs): + params = XPeelPeelerBackend.PeelParams(**kwargs) if kwargs else None + return await self._peeler.peel(backend_params=params) + + async def restart(self): + return await self._peeler.restart() + + async def reset(self): + return await self.driver.reset() + + async def get_status(self): + return await self.driver.request_status() + + async def get_version(self): + return await self.driver.request_version() + + async def seal_check(self): + return await self.driver.seal_check() + + async def get_tape_remaining(self): + return await self.driver.request_tape_remaining() + + async def enable_plate_check(self, enabled=True): + return await self.driver.enable_plate_check(enabled=enabled) + + async def get_seal_sensor_status(self): + return await self.driver.request_seal_sensor_status() + + async def set_seal_threshold_upper(self, value: int): + return await self.driver.set_seal_threshold_upper(value=value) + + async def set_seal_threshold_lower(self, value: int): + return await self.driver.set_seal_threshold_lower(value=value) + + async def move_conveyor_out(self): + return await self.driver.move_conveyor_out() + + async def move_conveyor_in(self): + return await self.driver.move_conveyor_in() + + async def move_elevator_down(self): + return await self.driver.move_elevator_down() + + async def move_elevator_up(self): + return await self.driver.move_elevator_up() + + async def advance_tape(self): + return await self.driver.advance_tape() diff --git a/pylabrobot/legacy/plate_reading/__init__.py b/pylabrobot/legacy/plate_reading/__init__.py new file mode 100644 index 00000000000..7802c4a33d2 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/__init__.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +from .agilent import ( + BioTekPlateReaderBackend, + 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, + 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/legacy/plate_reading/agilent/__init__.py b/pylabrobot/legacy/plate_reading/agilent/__init__.py new file mode 100644 index 00000000000..700006e1e47 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/__init__.py @@ -0,0 +1,3 @@ +from .biotek_backend import BioTekPlateReaderBackend +from .biotek_cytation_backend import CytationBackend, CytationImagingConfig +from .biotek_synergyh1_backend import SynergyH1Backend diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py new file mode 100644 index 00000000000..81e74d3a305 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py @@ -0,0 +1,208 @@ +"""Legacy. Use pylabrobot.agilent instead.""" + +from typing import Dict, List, Optional + +from pylabrobot.agilent.biotek.plate_readers import base as biotek +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + + +class BioTekPlateReaderBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.agilent.BioTekBackend instead.""" + + def __init__( + self, + timeout: float = 20, + device_id: Optional[str] = None, + ) -> None: + self._new = biotek.BioTekBackend(timeout=timeout, device_id=device_id) + + # Expose internals for subclass compatibility + @property + def io(self): + return self._new.io + + @io.setter + def io(self, value): + self._new.io = value + + @property + def timeout(self): + return self._new.timeout + + @timeout.setter + def timeout(self, value): + self._new.timeout = value + + @property + def _plate(self): + return self._new._plate + + @_plate.setter + def _plate(self, value): + self._new._plate = value + + @property + def _shaking(self): + return self._new._shaking + + @_shaking.setter + def _shaking(self, value): + self._new._shaking = value + + @property + def _slow_mode(self): + return self._new._slow_mode + + @_slow_mode.setter + def _slow_mode(self, value): + self._new._slow_mode = value + + @property + def _version(self): + return self._new._version + + @_version.setter + def _version(self, value): + self._new._version = value + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + @property + def version(self) -> str: + return self._new.version + + @property + def abs_wavelength_range(self): + return self._new.abs_wavelength_range + + @property + def focal_height_range(self): + return self._new.focal_height_range + + @property + def excitation_range(self): + return self._new.excitation_range + + @property + def emission_range(self): + return self._new.emission_range + + @property + def supports_heating(self) -> bool: + return self._new.supports_heating + + @property + def supports_cooling(self) -> bool: + return self._new.supports_cooling + + @property + def temperature_range(self): + return self._new.temperature_range + + async def send_command(self, command, parameter=None, wait_for_response=True, timeout=None): + return await self._new.send_command(command, parameter, wait_for_response, timeout) + + async def _read_until(self, terminator, timeout=None): + return await self._new._read_until(terminator, timeout) + + async def get_serial_number(self): + return await self._new.request_serial_number() + + async def get_firmware_version(self): + return await self._new.request_firmware_version() + + async def open(self, slow=False): + return await self._new.open(slow=slow) + + async def close(self, plate=None, slow=False): + return await self._new.close(plate=plate, slow=slow) + + async def home(self): + return await self._new.home() + + async def get_current_temperature(self): + return await self._new.request_current_temperature() + + async def set_temperature(self, temperature): + return await self._new.set_temperature(temperature) + + async def stop_heating_or_cooling(self): + return await self._new.stop_heating_or_cooling() + + def _parse_body(self, body): + return self._new._parse_body(body) + + async def set_plate(self, plate): + return await self._new.set_plate(plate) + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + results = await self._new.read_absorbance(plate=plate, wells=wells, wavelength=wavelength) + return [ + { + "wavelength": r.wavelength, + "data": r.data, + "temperature": r.temperature if r.temperature is not None else float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 + ) -> List[Dict]: + from pylabrobot.agilent.biotek.plate_readers.base import BioTekBackend + + params = BioTekBackend.LuminescenceParams(integration_time=integration_time) + results = await self._new.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, backend_params=params + ) + return [ + { + "data": r.data, + "temperature": r.temperature if r.temperature is not None else float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + results = await self._new.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "data": r.data, + "temperature": r.temperature if r.temperature is not None else float("nan"), + "time": r.timestamp, + } + for r in results + ] + + ShakeType = biotek.BioTekBackend.ShakeType + + async def shake(self, shake_type, frequency): + return await self._new.shake(shake_type, frequency) + + async def stop_shaking(self): + return await self._new.stop_shaking() diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py new file mode 100644 index 00000000000..72441f07e81 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py @@ -0,0 +1,146 @@ +"""Legacy. Use pylabrobot.agilent instead.""" + +from typing import Optional + +from pylabrobot.agilent.biotek.plate_readers.base import BioTekBackend +from pylabrobot.agilent.biotek.plate_readers.cytation import ( + CytationImagingConfig, + CytationMicroscopyBackend, +) +from pylabrobot.legacy.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources import Plate + + +class CytationBackend(BioTekPlateReaderBackend, ImagerBackend): + """Legacy. Use ``from pylabrobot.agilent.biotek import CytationMicroscopyBackend`` instead.""" + + _new: BioTekBackend + + def __init__( + self, + timeout: float = 20, + device_id: Optional[str] = None, + imaging_config: Optional[CytationImagingConfig] = None, + ) -> None: + self._new = BioTekBackend( + timeout=timeout, device_id=device_id, human_readable_device_name="Agilent BioTek Cytation" + ) + self._microscopy_backend = CytationMicroscopyBackend( + driver=self._new, imaging_config=imaging_config + ) + + @property + def imaging_config(self): + return self._microscopy_backend.imaging_config + + @imaging_config.setter + def imaging_config(self, value): + self._microscopy_backend.imaging_config = value + + async def setup(self, use_cam: bool = False) -> None: + await self._new.setup() + self._microscopy_backend._use_cam = use_cam + await self._microscopy_backend._on_setup() + + async def stop(self): + await self._microscopy_backend._on_stop() + await self._new.stop() + + @property + def supports_heating(self): + return True + + @property + def supports_cooling(self): + return True + + @property + def objectives(self): + return self._microscopy_backend.objectives + + @property + def filters(self): + return self._microscopy_backend.filters + + async def close(self, plate=None, slow=False): + await self._new.close(plate=plate, slow=slow) + + def start_acquisition(self): + self._microscopy_backend.start_acquisition() + + def stop_acquisition(self): + self._microscopy_backend.stop_acquisition() + + async def led_on(self, intensity=10): + await self._microscopy_backend.led_on(intensity=intensity) + + async def led_off(self): + await self._microscopy_backend.led_off() + + async def set_focus(self, focal_position): + await self._microscopy_backend.set_focus(focal_position) + + async def set_position(self, x, y): + await self._microscopy_backend.set_position(x, y) + + async def set_auto_exposure(self, auto_exposure): + await self._microscopy_backend.set_auto_exposure(auto_exposure) + + async def set_exposure(self, exposure): + await self._microscopy_backend.set_exposure(exposure) + + async def select(self, row, column): + await self._microscopy_backend.select(row, column) + + async def set_gain(self, gain): + await self._microscopy_backend.set_gain(gain) + + async def set_objective(self, objective): + await self._microscopy_backend.set_objective(objective) + + async def set_imaging_mode(self, mode, led_intensity): + await self._microscopy_backend.set_imaging_mode(mode, led_intensity) + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + **kwargs, + ) -> ImagingResult: + from pylabrobot.capabilities.microscopy.standard import ImagingMode as NewImagingMode + from pylabrobot.capabilities.microscopy.standard import Objective as NewObjective + + new_mode = NewImagingMode[mode.name] + new_objective = NewObjective[objective.name] + + result = await self._microscopy_backend.capture( + row=row, + column=column, + mode=new_mode, + objective=new_objective, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + **kwargs, + ) + return ImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py new file mode 100644 index 00000000000..ee95e1c60f3 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py @@ -0,0 +1,23 @@ +"""Legacy. Use pylabrobot.agilent instead.""" + +from pylabrobot.agilent.biotek.plate_readers.synergy import synergy_h1 +from pylabrobot.legacy.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend + + +class SynergyH1Backend(BioTekPlateReaderBackend): + """Legacy. Use pylabrobot.agilent.SynergyH1Backend instead.""" + + def __init__(self, timeout: float = 20, device_id=None) -> None: + self._new = synergy_h1.SynergyH1Backend(timeout=timeout, device_id=device_id) + + @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) diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/legacy/plate_reading/backend.py similarity index 93% rename from pylabrobot/plate_reading/backend.py rename to pylabrobot/legacy/plate_reading/backend.py index f793e18a023..7420e163b09 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, @@ -83,6 +83,8 @@ async def read_fluorescence( class ImagerBackend(MachineBackend, metaclass=ABCMeta): + """Legacy. Use pylabrobot.capabilities.microscopy.MicroscopyBackend instead.""" + @abstractmethod async def capture( self, diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/legacy/plate_reading/biotek_backend.py similarity index 100% rename from pylabrobot/plate_reading/biotek_backend.py rename to pylabrobot/legacy/plate_reading/biotek_backend.py 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/legacy/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py new file mode 100644 index 00000000000..7638483bb8b --- /dev/null +++ b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py @@ -0,0 +1,113 @@ +"""Legacy. Use pylabrobot.bmg_labtech instead.""" + +import sys +from typing import Dict, List, Optional, Tuple + +from pylabrobot.bmg_labtech.clariostar import ( + CLARIOstarAbsorbanceBackend, + CLARIOstarAbsorbanceParams, + CLARIOstarDriver, + CLARIOstarFluorescenceBackend, + CLARIOstarLuminescenceBackend, +) +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + +class CLARIOstarBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.bmg_labtech.CLARIOstar instead.""" + + def __init__(self, device_id: Optional[str] = None): + self.driver = CLARIOstarDriver(device_id=device_id) + self._absorbance = CLARIOstarAbsorbanceBackend(self.driver) + self._luminescence = CLARIOstarLuminescenceBackend(self.driver) + self._fluorescence = CLARIOstarFluorescenceBackend(self.driver) + + async def setup(self): + await self.driver.setup() + await self._absorbance._on_setup() + await self._luminescence._on_setup() + await self._fluorescence._on_setup() + + async def stop(self): + await self._fluorescence._on_stop() + await self._luminescence._on_stop() + await self._absorbance._on_stop() + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def open(self): + await self.driver.open() + + async def close(self, plate: Optional[Plate] = None): + await self.driver.close() + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float = 13 + ) -> List[Dict]: + results = await self._luminescence.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height + ) + return [ + { + "data": r.data, + "temperature": float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + report: Literal["OD", "transmittance"] = "OD", + ) -> List[Dict]: + params = CLARIOstarAbsorbanceParams(report=report) + results = await self._absorbance.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, backend_params=params + ) + return [ + { + "wavelength": r.wavelength, + "data": r.data, + "temperature": float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict[Tuple[int, int], Dict]]: + raise NotImplementedError("Not implemented yet") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class CLARIOStar: + def __init__(self, *args, **kwargs): + raise RuntimeError("`CLARIOStar` is deprecated. Please use `CLARIOStarBackend` instead.") + + +class CLARIOStarBackend: + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`CLARIOStarBackend` (capital 'S') is deprecated. Please use `CLARIOstarBackend` instead." + ) diff --git a/pylabrobot/plate_reading/byonoy/__init__.py b/pylabrobot/legacy/plate_reading/byonoy/__init__.py similarity index 67% rename from pylabrobot/plate_reading/byonoy/__init__.py rename to pylabrobot/legacy/plate_reading/byonoy/__init__.py index 9779834c3e9..a030282e63c 100644 --- a/pylabrobot/plate_reading/byonoy/__init__.py +++ b/pylabrobot/legacy/plate_reading/byonoy/__init__.py @@ -1,14 +1,21 @@ -from .byonoy_a96a import ( - byonoy_a96a, - byonoy_a96a_detection_unit, +"""Legacy. Use pylabrobot.byonoy instead.""" + +from pylabrobot.byonoy.absorbance_96 import ( + ByonoyAbsorbanceBaseUnit, byonoy_a96a_illumination_unit, byonoy_a96a_parking_unit, byonoy_sbs_adapter, ) +from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescenceBaseUnit + +from .byonoy_a96a import ( + ByonoyAbsorbance96Automate, + byonoy_a96a, + byonoy_a96a_detection_unit, +) from .byonoy_backend import ByonoyAbsorbance96AutomateBackend, ByonoyLuminescence96AutomateBackend from .byonoy_l96 import ( ByonoyLuminescence96Automate, - ByonoyLuminescenceBaseUnit, byonoy_l96, byonoy_l96_base_unit, byonoy_l96_reader_unit, diff --git a/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py new file mode 100644 index 00000000000..36584687be6 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py @@ -0,0 +1,40 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + +from typing import Tuple + +from pylabrobot.byonoy.absorbance_96 import ( + ByonoyAbsorbanceBaseUnit, + byonoy_a96a_illumination_unit, +) +from pylabrobot.legacy.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader +from pylabrobot.resources import Resource + + +class ByonoyAbsorbance96Automate(PlateReader, ByonoyAbsorbanceBaseUnit): + """Legacy. Use pylabrobot.byonoy.ByonoyAbsorbance96 instead.""" + + def __init__(self, name: str): + ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") + PlateReader.__init__( + self, + name=name + "_reader", + size_x=138, + size_y=95.7, + size_z=0, + backend=ByonoyAbsorbance96AutomateBackend(), + ) + + +def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96Automate: + """Legacy. Use pylabrobot.byonoy.byonoy_a96a_detection_unit instead.""" + return ByonoyAbsorbance96Automate(name=name) + + +def byonoy_a96a(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96Automate, Resource]: + """Legacy. Use pylabrobot.byonoy.byonoy_a96a instead.""" + 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/legacy/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py new file mode 100644 index 00000000000..16b4d2e341a --- /dev/null +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py @@ -0,0 +1,120 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + +from typing import Dict, List, Optional + +from pylabrobot.byonoy import absorbance_96, luminescence_96 +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + + +class ByonoyAbsorbance96AutomateBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.byonoy.ByonoyAbsorbance96Backend instead.""" + + def __init__(self) -> None: + self._new = absorbance_96.ByonoyAbsorbance96Backend() + + async def setup(self, verbose: bool = False, **backend_kwargs): + await self._new.setup(**backend_kwargs) + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def open(self) -> None: + raise NotImplementedError( + "byonoy cannot open by itself. you need to move the top module using a robot arm." + ) + + async def close(self, plate: Optional[Plate]) -> None: + raise NotImplementedError( + "byonoy cannot close by itself. you need to move the top module using a robot arm." + ) + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + results = await self._new.read_absorbance(plate=plate, wells=wells, wavelength=wavelength) + return [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[Dict]: + raise NotImplementedError("Absorbance plate reader does not support luminescence reading.") + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + raise NotImplementedError("Absorbance plate reader does not support fluorescence reading.") + + +class ByonoyLuminescence96AutomateBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.byonoy.ByonoyLuminescence96Backend instead.""" + + def __init__(self) -> None: + self._new = luminescence_96.ByonoyLuminescence96Backend() + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def open(self) -> None: + raise NotImplementedError( + "byonoy cannot open by itself. you need to move the top module using a robot arm." + ) + + async def close(self, plate: Optional[Plate]) -> None: + raise NotImplementedError( + "byonoy cannot close by itself. you need to move the top module using a robot arm." + ) + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + raise NotImplementedError( + "Luminescence plate reader does not support absorbance reading. " + "Use ByonoyAbsorbance96Automate instead." + ) + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 + ) -> List[Dict]: + from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescence96Backend + + params = ByonoyLuminescence96Backend.LuminescenceParams(integration_time=integration_time) + results = await self._new.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, backend_params=params + ) + return [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + raise NotImplementedError("Luminescence plate reader does not support fluorescence reading.") diff --git a/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py new file mode 100644 index 00000000000..a52557f37fa --- /dev/null +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py @@ -0,0 +1,66 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + +from typing import Optional, Tuple + +from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescenceBaseUnit +from pylabrobot.legacy.plate_reading.byonoy.byonoy_backend import ( + ByonoyLuminescence96AutomateBackend, +) +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader +from pylabrobot.resources import Coordinate + + +class ByonoyLuminescence96Automate(PlateReader): + """Legacy. Use pylabrobot.byonoy.ByonoyLuminescence96 instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + preferred_pickup_location: Optional[Coordinate] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + backend=ByonoyLuminescence96AutomateBackend(), + model="Byonoy L96 Reader Unit", + preferred_pickup_location=preferred_pickup_location, + ) + + +def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96Automate: + """Legacy. Use pylabrobot.byonoy.byonoy_l96_reader_unit instead.""" + return ByonoyLuminescence96Automate( + 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: + """Legacy. Use pylabrobot.byonoy.byonoy_l96_base_unit instead.""" + 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, ByonoyLuminescence96Automate]: + """Legacy. Use pylabrobot.byonoy.byonoy_l96 instead.""" + 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 diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96a.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py similarity index 51% rename from pylabrobot/plate_reading/byonoy/byonoy_l96a.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py index 56b2fe3d029..2f6946315b4 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_l96a.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py @@ -1,48 +1,40 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + from typing import Tuple +from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescenceBaseUnit from pylabrobot.resources import Coordinate -from .byonoy_l96 import ( - ByonoyLuminescence96Automate, - ByonoyLuminescenceBaseUnit, -) +from .byonoy_l96 import ByonoyLuminescence96Automate def byonoy_l96a_reader_unit(name: str) -> ByonoyLuminescence96Automate: - """Create a Byonoy L96A reader unit `PlateReader`.""" + """Legacy. Use pylabrobot.byonoy.byonoy_l96a_reader_unit instead.""" return ByonoyLuminescence96Automate( name=name, - size_x=138, # caliper - size_y=97.5, # caliper - size_z=41.7, # force z probing - preferred_pickup_location=Coordinate(x=69, y=48.75, z=33.2), # z = 41.7 - 8.5 + 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.""" + """Legacy. Use pylabrobot.byonoy.byonoy_l96a_base_unit instead.""" return ByonoyLuminescenceBaseUnit( name=name, - size_x=138, # caliper - size_y=97.5, # caliper - size_z=10.7, # force z probing - plate_holder_child_location=Coordinate(x=5.1, y=4.75, z=8), # caliper - reader_unit_holder_child_location=Coordinate(x=0, y=0, z=6.3), # z = 48 - 41.7 + 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, ByonoyLuminescence96Automate]: - """Creates a ByonoyLuminescenceBaseUnit and a PlateReader instance for L96A (automate). - - Args: - name: Base name for the resources. - assign: If True, the reader unit is assigned to the base unit's reader_unit_holder. - - Returns: - A tuple of (base_unit, reader_unit). - """ + """Legacy. Use pylabrobot.byonoy.byonoy_l96a instead.""" base_unit = byonoy_l96a_base_unit(name=name + "_base") reader_unit = byonoy_l96a_reader_unit(name=name + "_reader") if assign: 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 06cd42c868a..0c5563f0c29 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/legacy/plate_reading/chatterbox.py b/pylabrobot/legacy/plate_reading/chatterbox.py new file mode 100644 index 00000000000..1b7be66913d --- /dev/null +++ b/pylabrobot/legacy/plate_reading/chatterbox.py @@ -0,0 +1,89 @@ +from typing import Dict, List, Optional + +from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import ( + AbsorbanceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import ( + FluorescenceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import ( + LuminescenceChatterboxBackend, +) +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + + +class PlateReaderChatterboxBackend(PlateReaderBackend): + """Chatterbox plate reader backend. Delegates to the new capability chatterbox backends.""" + + def __init__(self): + self._absorbance = AbsorbanceChatterboxBackend() + self._fluorescence = FluorescenceChatterboxBackend() + self._luminescence = LuminescenceChatterboxBackend() + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def open(self) -> None: + pass + + async def close(self, plate: Optional[Plate]) -> None: + pass + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + results = await self._absorbance.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength + ) + return [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + results = await self._fluorescence.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[Dict]: + results = await self._luminescence.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height + ) + return [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] diff --git a/pylabrobot/plate_reading/clario_star_backend.py b/pylabrobot/legacy/plate_reading/clario_star_backend.py similarity index 100% rename from pylabrobot/plate_reading/clario_star_backend.py rename to pylabrobot/legacy/plate_reading/clario_star_backend.py diff --git a/pylabrobot/plate_reading/image_reader.py b/pylabrobot/legacy/plate_reading/image_reader.py similarity index 70% rename from pylabrobot/plate_reading/image_reader.py rename to pylabrobot/legacy/plate_reading/image_reader.py index e340ccf0f51..a2090821599 100644 --- a/pylabrobot/plate_reading/image_reader.py +++ b/pylabrobot/legacy/plate_reading/image_reader.py @@ -1,13 +1,13 @@ 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 class ImageReader(PlateReader, Imager): - """Microscope which is also a plate reader""" + """Legacy. Microscope which is also a plate reader.""" def __init__( self, diff --git a/pylabrobot/legacy/plate_reading/imager.py b/pylabrobot/legacy/plate_reading/imager.py new file mode 100644 index 00000000000..a8e954d47e3 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/imager.py @@ -0,0 +1,170 @@ +"""Legacy. Use pylabrobot.capabilities.microscopy.Microscopy instead.""" + +from typing import Optional, Tuple, Union, cast + +from pylabrobot.capabilities.microscopy import AutoExposure as NewAutoExposure +from pylabrobot.capabilities.microscopy import ImagingMode as NewImagingMode +from pylabrobot.capabilities.microscopy import ImagingResult as NewImagingResult +from pylabrobot.capabilities.microscopy import ( + Microscopy, + MicroscopyBackend, + evaluate_focus_nvmg_sobel, + fraction_overexposed, + max_pixel_at_fraction, +) +from pylabrobot.capabilities.microscopy import Objective as NewObjective +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, + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + NoPlateError, + Objective, +) +from pylabrobot.resources import Plate, Resource, Rotation, Well + +# Re-export helpers so existing imports still work. +__all__ = [ + "Imager", + "max_pixel_at_fraction", + "fraction_overexposed", + "evaluate_focus_nvmg_sobel", +] + + +class _ImagerBackendAdapter(MicroscopyBackend): + """Adapts a legacy ImagerBackend to the new MicroscopyBackend protocol.""" + + def __init__(self, legacy: ImagerBackend): + self._legacy = legacy + + async def setup(self) -> None: + await self._legacy.setup() + + async def stop(self) -> None: + await self._legacy.stop() + + async def capture( + self, + row, + column, + mode, + objective, + exposure_time, + focal_height, + gain, + plate, + backend_params=None, + ): + legacy_mode = ImagingMode[mode.name] + legacy_obj = Objective[objective.name] + result = await self._legacy.capture( + row=row, + column=column, + mode=legacy_mode, + objective=legacy_obj, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + ) + return NewImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) + + +def _to_new_imaging_mode(mode: ImagingMode) -> NewImagingMode: + return NewImagingMode[mode.name] + + +def _to_new_objective(obj: Objective) -> NewObjective: + return NewObjective[obj.name] + + +def _to_legacy_result(result: NewImagingResult) -> ImagingResult: + return ImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) + + +class Imager(Resource, Machine): + """Legacy. Use pylabrobot.molecular_devices.Pico (or similar Device) instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: ImagerBackend, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Machine.__init__(self, backend=backend) + self._backend: ImagerBackend = backend + self._microscopy = Microscopy(backend=_ImagerBackendAdapter(backend)) + self._microscopy._setup_finished = True # legacy Machine.setup() handles lifecycle + + self.register_will_assign_resource_callback(self._will_assign_resource) + + def _will_assign_resource(self, resource: Resource): + if len(self.children) >= 1: + raise ValueError( + f"Imager {self} already has a plate assigned (attempting to assign {resource})" + ) + + def get_plate(self) -> Plate: + if len(self.children) == 0: + raise NoPlateError("There is no plate in the plate reader.") + return cast(Plate, self.children[0]) + + @need_setup_finished + async def capture( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + exposure_time: Union[Exposure, AutoExposure] = "machine-auto", + focal_height: FocalPosition = "machine-auto", + gain: Gain = "machine-auto", + **backend_kwargs, + ) -> ImagingResult: + new_exposure: Union[float, str, NewAutoExposure] + if isinstance(exposure_time, AutoExposure): + new_exposure = NewAutoExposure( + evaluate_exposure=exposure_time.evaluate_exposure, + max_rounds=exposure_time.max_rounds, + low=exposure_time.low, + high=exposure_time.high, + ) + else: + new_exposure = exposure_time + new_result = await self._microscopy.capture( + well=well, + mode=_to_new_imaging_mode(mode), + objective=_to_new_objective(objective), + plate=self.get_plate(), + exposure_time=new_exposure, + focal_height=focal_height, + gain=gain, + ) + return _to_legacy_result(new_result) diff --git a/pylabrobot/plate_reading/molecular_devices/__init__.py b/pylabrobot/legacy/plate_reading/molecular_devices/__init__.py similarity index 90% rename from pylabrobot/plate_reading/molecular_devices/__init__.py rename to pylabrobot/legacy/plate_reading/molecular_devices/__init__.py index 4195d7f64ad..aa848a6b82d 100644 --- a/pylabrobot/plate_reading/molecular_devices/__init__.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/__init__.py @@ -1,8 +1,7 @@ -from .backend import ( +from pylabrobot.molecular_devices.spectramax.backend import ( Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, MolecularDevicesError, MolecularDevicesFirmwareError, MolecularDevicesHardwareError, @@ -17,6 +16,8 @@ ShakeSettings, SpectrumSettings, ) + +from .backend import MolecularDevicesBackend from .spectramax_384_plus_backend import MolecularDevicesSpectraMax384PlusBackend from .spectramax_m5_backend import MolecularDevicesSpectraMaxM5Backend 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..6236e37cbd9 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -0,0 +1,315 @@ +"""Legacy. Use pylabrobot.molecular_devices.spectramax instead.""" + +from typing import Dict, List, Optional, Tuple, Union + +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.molecular_devices.spectramax.backend import ( # noqa: F401 + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesError, + MolecularDevicesFirmwareError, + MolecularDevicesHardwareError, + MolecularDevicesMotionError, + MolecularDevicesNVRAMError, + MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import ( + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) +from pylabrobot.resources.plate import Plate + + +class MolecularDevicesBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.molecular_devices.spectramax instead. + + Delegates to the new capability-based backend, adapting read method signatures + and return types (List[Dict]) for backward compatibility. + """ + + def __init__(self, port: str) -> None: + self.driver = self._make_driver(port) + self._absorbance = MolecularDevicesAbsorbanceBackend(self.driver) + self._temperature = MolecularDevicesTemperatureBackend(self.driver) + self._fluorescence = SpectraMaxM5FluorescenceBackend(self.driver) + self._luminescence = SpectraMaxM5LuminescenceBackend(self.driver) + + def _make_driver(self, port: str): + return MolecularDevicesDriver(port=port) + + # -- PlateReaderBackend / MachineBackend interface ----------------------- + + async def setup(self) -> None: + await self.driver.setup() + + async def stop(self) -> None: + await self.driver.stop() + + async def open(self) -> None: + await self.driver.open() + + async def close(self, plate=None) -> None: + await self.driver.close() + + async def send_command(self, *args, **kwargs): + return await self.driver.send_command(*args, **kwargs) + + def serialize(self) -> dict: + return dict(self.driver.serialize()) + + # -- Bridged internals (must be explicit for class-level @patch) --------- + + async def _read_now(self): + return await self._absorbance._read_now() + + async def _wait_for_idle(self, **kwargs): + return await self.driver.wait_for_idle(**kwargs) + + async def _transfer_data(self, *args, **kwargs): + return await self._absorbance._transfer_data(*args, **kwargs) + + # -- Legacy read methods (delegate to _new, convert results) ------------- + + async def read_absorbance( # type: ignore[override] + self, + plate: Plate, + wavelengths: List[Union[int, Tuple[int, bool]]], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = 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[Dict]: + wl0 = wavelengths[0] + wavelength = wl0[0] if isinstance(wl0, tuple) else wl0 + params = MolecularDevicesAbsorbanceBackend.AbsorbanceParams( + wavelengths=wavelengths, + 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, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + results = await self._absorbance.read_absorbance( + plate=plate, + wells=[], + wavelength=wavelength, + backend_params=params, + ) + return [ + { + "wavelength": r.wavelength, + "data": r.data, + "temperature": r.temperature, + "time": r.timestamp, + } + for r in results + ] + + async def read_fluorescence( # type: ignore[override] + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.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]: + params = SpectraMaxM5FluorescenceBackend.FluorescenceParams( + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + 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, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + results = await self._fluorescence.read_fluorescence( + plate=plate, + wells=[], + excitation_wavelength=excitation_wavelengths[0], + emission_wavelength=emission_wavelengths[0], + focal_height=0, + backend_params=params, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "data": r.data, + "temperature": r.temperature, + "time": r.timestamp, + } + for r in results + ] + + async def read_luminescence( # type: ignore[override] + self, + plate: Plate, + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.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[Dict]: + params = SpectraMaxM5LuminescenceBackend.LuminescenceParams( + emission_wavelengths=emission_wavelengths, + 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, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + results = await self._luminescence.read_luminescence( + plate=plate, + wells=[], + focal_height=0, + backend_params=params, + ) + return [{"data": r.data, "temperature": r.temperature, "time": r.timestamp} for r in results] + + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.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]: + return await self._fluorescence.read_fluorescence_polarization( + plate=plate, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + 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, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + + 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 = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.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]: + return await self._fluorescence.read_time_resolved_fluorescence( + plate=plate, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + delay_time=delay_time, + integration_time=integration_time, + 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, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) diff --git a/pylabrobot/plate_reading/molecular_devices/backend_tests.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py similarity index 86% rename from pylabrobot/plate_reading/molecular_devices/backend_tests.py rename to pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py index 589d65386cb..a65f87e57b9 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, @@ -34,16 +34,16 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="COM1") - self.backend.io = self.mock_serial + self.backend.driver.io = self.mock_serial self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock + self.backend.driver, "send_command", new_callable=AsyncMock ).start() self.addCleanup(patch.stopall) async def test_setup_stop(self): # un-mock send_command for this test with patch.object( - self.backend, "send_command", wraps=self.backend.send_command + self.backend.driver, "send_command", wraps=self.backend.driver.send_command ) as wrapped_send_command: await self.backend.setup() self.mock_serial.setup.assert_called_once() @@ -52,7 +52,7 @@ async def test_setup_stop(self): self.mock_serial.stop.assert_called_once() async def test_set_clear(self): - await self.backend._set_clear() + await self.backend._absorbance._set_clear() self.send_command_mock.assert_called_once_with("!CLEAR DATA") async def test_set_mode(self): @@ -68,24 +68,24 @@ async def test_set_mode(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE ENDPOINT") self.send_command_mock.reset_mock() settings.read_type = ReadType.KINETIC settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - await self.backend._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE KINETIC 10 5") self.send_command_mock.reset_mock() settings.read_type = ReadType.SPECTRUM settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) - await self.backend._set_mode(settings) + await self.backend._absorbance._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.backend._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") async def test_set_wavelengths(self): @@ -102,25 +102,25 @@ async def test_set_wavelengths(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_wavelengths(settings) + await self.backend._absorbance._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.backend._set_wavelengths(settings) + await self.backend._absorbance._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 = ReadMode.FLU settings.excitation_wavelengths = [485] settings.emission_wavelengths = [520] - await self.backend._set_wavelengths(settings) + await self.backend._absorbance._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 = ReadMode.LUM settings.emission_wavelengths = [590] - await self.backend._set_wavelengths(settings) + await self.backend._absorbance._set_wavelengths(settings) self.send_command_mock.assert_called_once_with("!EMWAVELENGTH 590") async def test_set_plate_position(self): @@ -137,7 +137,7 @@ async def test_set_plate_position(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_plate_position(settings) + await self.backend._absorbance._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")] ) @@ -156,7 +156,7 @@ async def test_set_strip(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_strip(settings) + await self.backend._absorbance._set_strip(settings) self.send_command_mock.assert_called_once_with("!STRIP 1 12") async def test_set_shake(self): @@ -172,18 +172,18 @@ async def test_set_shake(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_shake(settings) + await self.backend._absorbance._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.backend._set_shake(settings) + await self.backend._absorbance._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.backend._set_shake(settings) + await self.backend._absorbance._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): @@ -199,11 +199,11 @@ async def test_set_carriage_speed(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_carriage_speed(settings) + await self.backend._absorbance._set_carriage_speed(settings) self.send_command_mock.assert_called_once_with("!CSPEED 8") self.send_command_mock.reset_mock() settings.carriage_speed = CarriageSpeed.SLOW - await self.backend._set_carriage_speed(settings) + await self.backend._absorbance._set_carriage_speed(settings) self.send_command_mock.assert_called_once_with("!CSPEED 1") async def test_set_read_stage(self): @@ -219,15 +219,15 @@ async def test_set_read_stage(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_read_stage(settings) + await self.backend._absorbance._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.backend._set_read_stage(settings) + await self.backend._absorbance._set_read_stage(settings) self.send_command_mock.assert_called_once_with("!READSTAGE BOT") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS - await self.backend._set_read_stage(settings) + await self.backend._absorbance._set_read_stage(settings) self.send_command_mock.assert_not_called() async def test_set_flashes_per_well(self): @@ -244,11 +244,11 @@ async def test_set_flashes_per_well(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_flashes_per_well(settings) + await self.backend._absorbance._set_flashes_per_well(settings) self.send_command_mock.assert_called_once_with("!FPW 10") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS - await self.backend._set_flashes_per_well(settings) + await self.backend._absorbance._set_flashes_per_well(settings) self.send_command_mock.assert_not_called() async def test_set_pmt(self): @@ -265,19 +265,19 @@ async def test_set_pmt(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_pmt(settings) + await self.backend._absorbance._set_pmt(settings) self.send_command_mock.assert_called_once_with("!AUTOPMT ON") self.send_command_mock.reset_mock() settings.pmt_gain = PmtGain.HIGH - await self.backend._set_pmt(settings) + await self.backend._absorbance._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.backend._set_pmt(settings) + await self.backend._absorbance._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 = ReadMode.ABS - await self.backend._set_pmt(settings) + await self.backend._absorbance._set_pmt(settings) self.send_command_mock.assert_not_called() async def test_set_filter(self): @@ -290,20 +290,20 @@ async def test_set_filter(self): shake_settings=None, carriage_speed=CarriageSpeed.NORMAL, speed_read=False, - cutoff_filters=[self.backend._get_cutoff_filter_index_from_wavelength(535), 9], + cutoff_filters=[self.backend._absorbance._get_cutoff_filter_index_from_wavelength(535), 9], kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_filter(settings) + await self.backend._absorbance._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.backend._set_filter(settings) + await self.backend._absorbance._set_filter(settings) self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS settings.cutoff_filters = [515, 530] - await self.backend._set_filter(settings) + await self.backend._absorbance._set_filter(settings) self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") async def test_set_calibrate(self): @@ -319,11 +319,11 @@ async def test_set_calibrate(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_calibrate(settings) + await self.backend._absorbance._set_calibrate(settings) self.send_command_mock.assert_called_once_with("!CALIBRATE ON") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU - await self.backend._set_calibrate(settings) + await self.backend._absorbance._set_calibrate(settings) self.send_command_mock.assert_called_once_with("!PMTCAL ON") async def test_set_order(self): @@ -339,11 +339,11 @@ async def test_set_order(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_order(settings) + await self.backend._absorbance._set_order(settings) self.send_command_mock.assert_called_once_with("!ORDER COLUMN") self.send_command_mock.reset_mock() settings.read_order = ReadOrder.WAVELENGTH - await self.backend._set_order(settings) + await self.backend._absorbance._set_order(settings) self.send_command_mock.assert_called_once_with("!ORDER WAVELENGTH") async def test_set_speed(self): @@ -359,15 +359,15 @@ async def test_set_speed(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_speed(settings) + await self.backend._absorbance._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.backend._set_speed(settings) + await self.backend._absorbance._set_speed(settings) self.send_command_mock.assert_called_once_with("!SPEED OFF") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU - await self.backend._set_speed(settings) + await self.backend._absorbance._set_speed(settings) self.send_command_mock.assert_not_called() async def test_set_integration_time(self): @@ -383,11 +383,11 @@ async def test_set_integration_time(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._set_integration_time(settings, 10, 100) + await self.backend._absorbance._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 = ReadMode.ABS - await self.backend._set_integration_time(settings, 10, 100) + await self.backend._absorbance._set_integration_time(settings, 10, 100) self.send_command_mock.assert_not_called() async def test_set_nvram_polar(self): @@ -404,7 +404,7 @@ async def test_set_nvram_polar(self): spectrum_settings=None, settling_time=5, ) - await self.backend._set_nvram(settings) + await self.backend._absorbance._set_nvram(settings) self.send_command_mock.assert_called_once_with("!NVRAM FPSETTLETIME 5") async def test_set_nvram_other(self): @@ -421,11 +421,11 @@ async def test_set_nvram_other(self): spectrum_settings=None, settling_time=10, ) - await self.backend._set_nvram(settings) + await self.backend._absorbance._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.backend._set_nvram(settings) + await self.backend._absorbance._set_nvram(settings) self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 110") async def test_set_tag(self): @@ -441,29 +441,29 @@ async def test_set_tag(self): kinetic_settings=KineticSettings(interval=10, num_readings=5), spectrum_settings=None, ) - await self.backend._set_tag(settings) + await self.backend._absorbance._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG ON") self.send_command_mock.reset_mock() settings.read_type = ReadType.ENDPOINT - await self.backend._set_tag(settings) + await self.backend._absorbance._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG OFF") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS settings.read_type = ReadType.KINETIC - await self.backend._set_tag(settings) + await self.backend._absorbance._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG OFF") @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -491,16 +491,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.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -535,16 +535,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.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -574,16 +574,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.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence_polarization( @@ -623,16 +623,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.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_time_resolved_fluorescence( @@ -683,7 +683,7 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): self.backend = MolecularDevicesBackend(port="COM1") self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock + self.backend.driver, "send_command", new_callable=AsyncMock ).start() def test_parse_absorbance_single_wavelength(self): @@ -707,7 +707,7 @@ def test_parse_absorbance_single_wavelength(self): spectrum_settings=None, ) - result = self.backend._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -739,7 +739,7 @@ def test_parse_absorbance_multiple_wavelengths(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 2) self.assertEqual(result[0]["wavelength"], 260) @@ -768,7 +768,7 @@ def test_parse_fluorescence(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -798,7 +798,7 @@ def test_parse_luminescence(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -827,7 +827,7 @@ def test_parse_data_with_sat_and_nan(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -873,7 +873,7 @@ def data_generator(): spectrum_settings=None, ) - result = await self.backend._transfer_data(settings) + result = await self.backend._absorbance._transfer_data(settings) self.assertEqual(len(result), 2) self.assertEqual(result[0]["wavelength"], 260) self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) @@ -921,7 +921,7 @@ def data_generator(): kinetic_settings=None, ) - result = await self.backend._transfer_data(settings) + result = await self.backend._absorbance._transfer_data(settings) self.assertEqual(len(result), 2) self.assertEqual(result[0]["wavelength"], 260) @@ -943,7 +943,7 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="/dev/tty01") - self.backend.io = self.mock_serial + self.backend.driver.io = self.mock_serial async def _mock_send_command_response(self, response_str: str): self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] 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 83% 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 index ce3bbc758e5..507a7576e00 100644 --- a/pylabrobot/plate_reading/molecular_devices/spectramax_384_plus_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py @@ -1,37 +1,38 @@ -from typing import Dict, List, Optional, Union +"""Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMax384PlusBackend instead.""" -from pylabrobot.resources.plate import Plate +from typing import Dict, List, Optional, Union -from .backend import ( +from pylabrobot.molecular_devices.spectramax.backend import ( Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, - MolecularDevicesSettings, + MolecularDevicesDriver, PmtGain, ReadOrder, ReadType, ShakeSettings, SpectrumSettings, ) +from pylabrobot.molecular_devices.spectramax.spectramax_384_plus import ( + SpectraMax384PlusAbsorbanceBackend, +) +from pylabrobot.resources.plate import Plate + +from .backend import MolecularDevicesBackend class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): - """Backend for Molecular Devices SpectraMax 384 Plus plate readers.""" + """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMax384Plus instead.""" + + def _make_driver(self, port: str): + return MolecularDevicesDriver( + port=port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus" + ) def __init__(self, port: str) -> None: super().__init__(port) - - async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: - """Set the READTYPE command and the expected number of response fields.""" - 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 + # Override the absorbance backend with the 384-specific one + self._absorbance = SpectraMax384PlusAbsorbanceBackend(self.driver) async def read_fluorescence( # type: ignore[override] self, 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 53% rename from pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py index 7628fbbf80d..0f78245d02f 100644 --- a/pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py @@ -1,8 +1,10 @@ +"""Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5 instead.""" + from .backend import MolecularDevicesBackend class MolecularDevicesSpectraMaxM5Backend(MolecularDevicesBackend): - """Backend for Molecular Devices SpectraMax M5 plate readers.""" + """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5 instead.""" def __init__(self, port: str) -> None: super().__init__(port) diff --git a/pylabrobot/legacy/plate_reading/plate_reader.py b/pylabrobot/legacy/plate_reading/plate_reader.py new file mode 100644 index 00000000000..a3d5c3047c6 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/plate_reader.py @@ -0,0 +1,359 @@ +import logging +from typing import Dict, List, Optional, cast + +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.absorbance.backend import ( + AbsorbanceBackend as _NewAbsorbanceBackend, +) +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.fluorescence.backend import ( + FluorescenceBackend as _NewFluorescenceBackend, +) +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.capabilities.plate_reading.luminescence.backend import ( + LuminescenceBackend as _NewLuminescenceBackend, +) +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.legacy._backend_params import _DictBackendParams +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 +from pylabrobot.serializer import SerializableMixin + +logger = logging.getLogger(__name__) + + +class _AbsorbanceAdapter(_NewAbsorbanceBackend): + """Adapts PlateReaderBackend.read_absorbance to AbsorbanceBackend.""" + + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + kwargs = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + dicts = await self._legacy.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, **kwargs + ) + return [ + AbsorbanceResult( + data=d["data"], + wavelength=d["wavelength"], + temperature=d.get("temperature"), + timestamp=d.get("time", 0), + ) + for d in dicts + ] + + +class _LuminescenceAdapter(_NewLuminescenceBackend): + """Adapts PlateReaderBackend.read_luminescence to LuminescenceBackend.""" + + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + kwargs = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + dicts = await self._legacy.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, **kwargs + ) + return [ + LuminescenceResult( + data=d["data"], + temperature=d.get("temperature"), + timestamp=d.get("time", 0), + ) + for d in dicts + ] + + +class _FluorescenceAdapter(_NewFluorescenceBackend): + """Adapts PlateReaderBackend.read_fluorescence to FluorescenceBackend.""" + + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + kwargs = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + dicts = await self._legacy.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + **kwargs, + ) + return [ + FluorescenceResult( + data=d["data"], + excitation_wavelength=d["ex_wavelength"], + emission_wavelength=d["em_wavelength"], + temperature=d.get("temperature"), + timestamp=d.get("time", 0), + ) + for d in dicts + ] + + +class PlateReader(ResourceHolder, Machine): + """The front end for plate readers. Plate readers are devices that can read luminescence, + absorbance, or fluorescence from a plate. + + Plate readers are asynchronous, meaning that their methods will return immediately and + will not block. + + Here's an example of how to use this class in a Jupyter Notebook: + + >>> from pylabrobot.plate_reading.clario_star import CLARIOStarBackend + >>> pr = PlateReader(backend=CLARIOStarBackend()) + >>> pr.setup() + >>> await pr.read_luminescence() + [[value1, value2, value3, ...], [value1, value2, value3, ...], ... + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: PlateReaderBackend, + rotation: Optional["Rotation"] = None, + category: Optional[str] = "plate_reader", + model: Optional[str] = None, + child_location: Coordinate = Coordinate.zero(), + preferred_pickup_location: Optional[Coordinate] = None, + ) -> None: + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + child_location=child_location, + preferred_pickup_location=preferred_pickup_location, + ) + Machine.__init__(self, backend=backend) + self.backend: PlateReaderBackend = backend # fix type + + self._absorbance_cap = Absorbance(backend=_AbsorbanceAdapter(backend)) + self._luminescence_cap = Luminescence(backend=_LuminescenceAdapter(backend)) + self._fluorescence_cap = Fluorescence(backend=_FluorescenceAdapter(backend)) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._absorbance_cap._on_setup() + await self._luminescence_cap._on_setup() + await self._fluorescence_cap._on_setup() + + async def stop(self): + await self._fluorescence_cap._on_stop() + await self._luminescence_cap._on_stop() + await self._absorbance_cap._on_stop() + await super().stop() + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + ): + if len([c for c in self.children if isinstance(c, Plate)]) >= 1: + raise ValueError("There already is a plate in the plate reader.") + + super().assign_child_resource(resource, location=location, reassign=reassign) + + def get_plate(self) -> Plate: + plate_children = [c for c in self.children if isinstance(c, Plate)] + if len(plate_children) == 0: + raise NoPlateError("There is no plate in the plate reader.") + return cast(Plate, plate_children[0]) + + @need_setup_finished + async def open(self, **backend_kwargs) -> None: + await self.backend.open(**backend_kwargs) + + @need_setup_finished + async def close(self, **backend_kwargs) -> None: + plate = self.get_plate() if len(self.children) > 0 else None + await self.backend.close(plate=plate, **backend_kwargs) + + @need_setup_finished + async def read_luminescence( + self, + focal_height: float, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, + ) -> List[Dict]: + """Read the luminescence from the plate reader. + + Args: + focal_height: The focal height to read the luminescence at, in millimeters. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. + + Returns: + A list of dictionaries, one for each measurement. Each dictionary contains: + "time": float, + "temperature": float, + "data": List[List[float]] + """ + + plate = self.get_plate() + results = await self._luminescence_cap.read( + plate=plate, + wells=wells or plate.get_all_items(), + focal_height=focal_height, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + result = [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + if not use_new_return_type: + logger.warning( + "The return type of read_luminescence will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] # type: ignore[no-any-return, return-value] + return result + + @need_setup_finished + async def read_absorbance( + self, + wavelength: int, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, + ) -> List[Dict]: + """Read the absorbance from the plate reader. + + Args: + wavelength: The wavelength to read the absorbance at, in nanometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. + + Returns: + A list of dictionaries, one for each measurement. Each dictionary contains: + "wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] + """ + + plate = self.get_plate() + results = await self._absorbance_cap.read( + plate=plate, + wells=wells or plate.get_all_items(), + wavelength=wavelength, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + result = [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + if not use_new_return_type: + logger.warning( + "The return type of read_absorbance will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] # type: ignore[no-any-return, return-value] + return result + + @need_setup_finished + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, + ) -> List[Dict]: + """Read the fluorescence from the plate reader. + + Args: + excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers. + emission_wavelength: The emission wavelength to read the fluorescence at, in nanometers. + focal_height: The focal height to read the fluorescence at, in millimeters. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. + + Returns: + A list of dictionaries, one for each measurement. Each dictionary contains: + "ex_wavelength": int, + "em_wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] + """ + + if excitation_wavelength > emission_wavelength: + logger.warning( + "Excitation wavelength is greater than emission wavelength. This is unusual and may indicate an error." + ) + + plate = self.get_plate() + results = await self._fluorescence_cap.read( + plate=plate, + wells=wells or plate.get_all_items(), + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) + result = [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + if not use_new_return_type: + logger.warning( + "The return type of read_fluorescence will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] # type: ignore[no-any-return, return-value] + return result + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Machine.serialize(self)} 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/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/legacy/plate_reading/tecan/infinite_backend.py b/pylabrobot/legacy/plate_reading/tecan/infinite_backend.py new file mode 100644 index 00000000000..496c29401ad --- /dev/null +++ b/pylabrobot/legacy/plate_reading/tecan/infinite_backend.py @@ -0,0 +1,302 @@ +"""Tecan Infinite 200 PRO backend. + +Legacy wrapper. Use :class:`pylabrobot.tecan.infinite.TecanInfinite200Pro` instead. + +This module delegates to the new Device/Driver/CapabilityBackend architecture +while preserving the legacy ``PlateReaderBackend`` API and all internal symbols +imported by existing tests. +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, Optional + +from pylabrobot.io.usb import USB # noqa: F401 — test patches this import location +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate +from pylabrobot.resources.well import Well +from pylabrobot.tecan.infinite.absorbance_backend import ( + TecanInfiniteAbsorbanceBackend, + TecanInfiniteAbsorbanceParams, +) +from pylabrobot.tecan.infinite.driver import TecanInfiniteDriver +from pylabrobot.tecan.infinite.fluorescence_backend import ( + TecanInfiniteFluorescenceBackend, + TecanInfiniteFluorescenceParams, +) +from pylabrobot.tecan.infinite.luminescence_backend import ( + TecanInfiniteLuminescenceBackend, + TecanInfiniteLuminescenceParams, +) + +# Re-export protocol symbols so existing test imports continue to work. +from pylabrobot.tecan.infinite.protocol import ( # noqa: F401 + BIN_RE, + StagePosition, + _AbsorbanceCalibration, + _AbsorbanceCalibrationItem, + _AbsorbanceMeasurement, + _AbsorbanceRunDecoder, + _FluorescenceCalibration, + _FluorescenceRunDecoder, + _LuminescenceCalibration, + _LuminescenceMeasurement, + _LuminescenceRunDecoder, + _MeasurementDecoder, + _StreamEvent, + _StreamParser, + _absorbance_od_calibrated, + _consume_leading_ascii_frame, + _consume_status_frame, + _decode_abs_calibration, + _decode_abs_data, + _decode_flr_calibration, + _decode_flr_data, + _decode_lum_calibration, + _decode_lum_data, + _fluorescence_corrected, + _integration_microseconds_to_seconds, + _is_abs_calibration_len, + _is_abs_data_len, + _luminescence_intensity, + _split_payload_and_trailer, + format_plate_result, + frame_command, + is_terminal_frame, +) + +logger = logging.getLogger(__name__) + + +class ExperimentalTecanInfinite200ProBackend(PlateReaderBackend): + """Legacy wrapper around the new Tecan Infinite architecture. + + Use :class:`pylabrobot.tecan.infinite.TecanInfinite200Pro` for new code. + """ + + VENDOR_ID = TecanInfiniteDriver.VENDOR_ID + PRODUCT_ID = TecanInfiniteDriver.PRODUCT_ID + + _MODE_CAPABILITY_COMMANDS = TecanInfiniteDriver._MODE_CAPABILITY_COMMANDS + + def __init__( + self, + counts_per_mm_x: float = 1_000, + counts_per_mm_y: float = 1_000, + counts_per_mm_z: float = 1_000, + ) -> None: + super().__init__() + # Create USB here so that test patches on + # "pylabrobot.legacy.plate_reading.tecan.infinite_backend.USB" + # are picked up. Pass the io instance to the driver. + io = USB( + id_vendor=self.VENDOR_ID, + id_product=self.PRODUCT_ID, + human_readable_device_name="Tecan Infinite 200 PRO", + packet_read_timeout=3, + read_timeout=30, + ) + self._driver = TecanInfiniteDriver( + counts_per_mm_x=counts_per_mm_x, + counts_per_mm_y=counts_per_mm_y, + counts_per_mm_z=counts_per_mm_z, + io=io, + ) + self._absorbance = TecanInfiniteAbsorbanceBackend(self._driver) + self._fluorescence = TecanInfiniteFluorescenceBackend(self._driver) + self._luminescence = TecanInfiniteLuminescenceBackend(self._driver) + + # Alias for direct attribute access (legacy code) + self.io = io + self.counts_per_mm_x = counts_per_mm_x + self.counts_per_mm_y = counts_per_mm_y + self.counts_per_mm_z = counts_per_mm_z + + # -- state proxies for test compat -- + + @property + def _ready(self): + return self._driver._ready + + @_ready.setter + def _ready(self, value): + self._driver._ready = value + + @property + def _pending_bin_events(self): + return self._driver._pending_bin_events + + @_pending_bin_events.setter + def _pending_bin_events(self, value): + self._driver._pending_bin_events = value + + @property + def _mode_capabilities(self): + return self._driver._mode_capabilities + + @property + def _parser(self): + return self._driver._parser + + @property + def _run_active(self): + return self._driver._run_active + + @property + def _active_step_loss_commands(self): + return self._driver._active_step_loss_commands + + @property + def _read_chunk_size(self): + return self._driver._read_chunk_size + + @property + def _max_row_wait_s(self): + return self._driver._max_row_wait_s + + # -- lifecycle -- + + async def setup(self) -> None: + await self._driver.setup() + + async def stop(self) -> None: + await self._driver.stop() + + # -- tray -- + + async def open(self) -> None: + await self._driver.open_tray() + + async def close(self, plate: Optional[Plate] = None) -> None: # noqa: ARG002 + await self._driver.close_tray() + + # -- reads: delegate to backends, convert Result -> dict -- + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + flashes: int = 25, + bandwidth: Optional[float] = None, + ) -> List[Dict]: + params = TecanInfiniteAbsorbanceParams(flashes=flashes, bandwidth=bandwidth) + results = await self._absorbance.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, backend_params=params, + ) + return [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float = 20.0, + flashes: int = 25, + integration_us: int = 20, + gain: int = 100, + excitation_bandwidth: int = 50, + emission_bandwidth: int = 200, + lag_us: int = 0, + ) -> List[Dict]: + params = TecanInfiniteFluorescenceParams( + flashes=flashes, + integration_us=integration_us, + gain=gain, + excitation_bandwidth=excitation_bandwidth, + emission_bandwidth=emission_bandwidth, + lag_us=lag_us, + ) + results = await self._fluorescence.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + backend_params=params, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float = 20.0, + flashes: int = 25, + dark_integration_us: int = 3_000_000, + meas_integration_us: int = 1_000_000, + ) -> List[Dict]: + params = TecanInfiniteLuminescenceParams( + flashes=flashes, + dark_integration_us=dark_integration_us, + meas_integration_us=meas_integration_us, + ) + results = await self._luminescence.read_luminescence( + plate=plate, + wells=wells, + focal_height=focal_height, + backend_params=params, + ) + return [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + # -- method delegates for test compat -- + + @staticmethod + def _frame_command(command: str) -> bytes: + return frame_command(command) + + @staticmethod + def _is_terminal_frame(text: str) -> bool: + return is_terminal_frame(text) + + def _scan_visit_order(self, wells, serpentine=True): + return self._driver.scan_visit_order(wells, serpentine) + + def _group_by_row(self, wells): + return self._driver.group_by_row(wells) + + def _scan_range(self, row_index, row_wells, serpentine=True): + return self._driver.scan_range(row_index, row_wells, serpentine) + + def _map_well_to_stage(self, well): + return self._driver.map_well_to_stage(well) + + def _format_plate_result(self, plate, scan_wells, values): + return format_plate_result(plate, scan_wells, values) + + def _capability_numeric(self, mode, command, fallback): + return self._driver.capability_numeric(mode, command, fallback) + + async def _send_command(self, command, **kwargs): + return await self._driver.send_command(command, **kwargs) + + +__all__ = [ + "ExperimentalTecanInfinite200ProBackend", +] diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py similarity index 94% rename from pylabrobot/plate_reading/tecan/infinite_backend_tests.py rename to pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py index 59e3f797bec..1501e4a4a82 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, @@ -606,7 +606,7 @@ def test_scan_visit_order_linear(self): self.assertEqual(identifiers, ["A1", "A2", "A3", "B1", "B2", "B3"]) def test_scan_range_serpentine(self): - setattr(self.backend, "_map_well_to_stage", lambda well: (well.get_column(), well.get_row())) + setattr(self.backend._driver, "map_well_to_stage", lambda well: (well.get_column(), well.get_row())) row_index, row_wells = self.backend._group_by_row(self.plate.get_all_items())[0] start_x, end_x, count = self.backend._scan_range(row_index, row_wells, serpentine=True) self.assertEqual((start_x, end_x, count), (0, 2, 3)) @@ -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() @@ -704,8 +704,8 @@ async def mock_await(decoder, row_count, mode): data_len, data_blob = _abs_data_blob(6000, 500, 1000) decoder.feed_bin(data_len, data_blob) - with patch.object(self.backend, "_await_measurements", side_effect=mock_await): - with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + with patch.object(self.backend._driver, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend._driver, "_await_scan_terminal", new_callable=AsyncMock): await self.backend.read_absorbance(self.plate, [], wavelength=600) self.mock_usb.write.assert_has_calls( @@ -765,8 +765,8 @@ async def mock_terminal(_saw_terminal): cal_len, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) self.backend._pending_bin_events.append((cal_len, cal_blob)) - with patch.object(self.backend, "_await_measurements", side_effect=mock_await): - with patch.object(self.backend, "_await_scan_terminal", side_effect=mock_terminal): + with patch.object(self.backend._driver, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend._driver, "_await_scan_terminal", side_effect=mock_terminal): result = await self.backend.read_absorbance(self.plate, [], wavelength=600) self.assertAlmostEqual(result[0]["data"][0][0], 0.3010299956639812) @@ -783,8 +783,8 @@ async def mock_await(decoder, row_count, mode): data_len, data_blob = _abs_data_blob(6000, 500, 1000) decoder.feed_bin(data_len, data_blob) - with patch.object(self.backend, "_await_measurements", side_effect=mock_await): - with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + with patch.object(self.backend._driver, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend._driver, "_await_scan_terminal", new_callable=AsyncMock): result = await self.backend.read_absorbance(self.plate, wells, wavelength=600) self.mock_usb.write.assert_has_calls( @@ -817,8 +817,8 @@ async def mock_await(decoder, row_count, mode): data_len, data_blob = _flr_data_blob(4850, 5200, 500, 1000) decoder.feed_bin(data_len, data_blob) - with patch.object(self.backend, "_await_measurements", side_effect=mock_await): - with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + with patch.object(self.backend._driver, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend._driver, "_await_scan_terminal", new_callable=AsyncMock): await self.backend.read_fluorescence( self.plate, [], excitation_wavelength=485, emission_wavelength=520 ) @@ -892,8 +892,8 @@ async def mock_await(decoder, row_count, mode): data_len, data_blob = _lum_data_blob(0, 1000) decoder.feed_bin(data_len, data_blob) - with patch.object(self.backend, "_await_measurements", side_effect=mock_await): - with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + with patch.object(self.backend._driver, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend._driver, "_await_scan_terminal", new_callable=AsyncMock): await self.backend.read_luminescence(self.plate, [], focal_height=14.62) self.mock_usb.write.assert_has_calls( @@ -952,8 +952,8 @@ async def mock_await(decoder, row_count, mode): data_len, data_blob = _lum_data_blob(0, 1000) decoder.feed_bin(data_len, data_blob) - with patch.object(self.backend, "_await_measurements", side_effect=mock_await): - with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + with patch.object(self.backend._driver, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend._driver, "_await_scan_terminal", new_callable=AsyncMock): await self.backend.read_luminescence(self.plate, []) self.mock_usb.write.assert_any_call(self._frame("POSITION LUM,Z=20000")) 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 98% rename from pylabrobot/plate_reading/tecan/spark20m/spark_backend.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py index e16cfc5f4c7..76b5414591a 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 -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 90% rename from pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py index 441935c4097..75d2c8ba650 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() 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 96% rename from pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py index 8eda39ac690..aa34f6f13e0 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_fluorescence, ) @@ -65,7 +65,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([]) @@ -83,7 +83,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([]) @@ -92,7 +92,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, []) @@ -117,7 +118,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([]) @@ -258,7 +259,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([]) @@ -277,7 +278,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([]) @@ -296,7 +297,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([]) 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 100% rename from pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async.py 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 95% 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..ebbb14c81f8 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() @@ -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) @@ -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"}} @@ -301,7 +306,7 @@ def execute_sync(func, *args): mock_reader._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 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/legacy/plate_washing/biotek/el406/actions.py b/pylabrobot/legacy/plate_washing/biotek/el406/actions.py new file mode 100644 index 00000000000..6e7999d50a4 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/actions.py @@ -0,0 +1,6 @@ +"""EL406 action and control methods — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.driver. +""" + +from pylabrobot.agilent.biotek.el406.driver import EL406Driver as EL406ActionsMixin # noqa: F401 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/legacy/plate_washing/biotek/el406/backend.py b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py new file mode 100644 index 00000000000..a92f762d4c7 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py @@ -0,0 +1,529 @@ +"""BioTek EL406 plate washer backend. + +This module provides the backend implementation for the BioTek EL406 +plate washer, communicating via FTDI USB serial interface. + +Protocol Details: +- Serial: 38400 baud, 8 data bits, 2 stop bits, no parity +- Flow control: disabled (no flow control) +- ACK byte: 0x06 +- Commands are binary with little-endian encoding +- Read timeout: 15000ms, Write timeout: 5000ms +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Literal, Optional + +from pylabrobot.agilent.biotek.el406.driver import EL406Driver +from pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend8 import ( + Cassette, + EL406PeristalticDispensingBackend8, + PeristalticFlowRate, +) +from pylabrobot.agilent.biotek.el406.plate_washing_backend import EL406PlateWasher96Backend +from pylabrobot.agilent.biotek.el406.shaking_backend import EL406ShakingBackend +from pylabrobot.agilent.biotek.el406.syringe_dispensing_backend8 import ( + EL406SyringeDispensingBackend8, + Syringe, +) +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.resources import Plate + +from .helpers import plate_to_wire_byte + +logger = logging.getLogger(__name__) + + +class ExperimentalBioTekEL406Backend( + MachineBackend, +): + """Backend for BioTek EL406 plate washer. + + Communicates with the EL406 via FTDI USB interface. + + Attributes: + timeout: Default timeout for operations in seconds. + + Example: + >>> backend = BioTekEL406Backend() + >>> await backend.setup() + >>> await backend.peristaltic_prime(plate, volume=300.0, flow_rate="High") + >>> await backend.wash(plate, cycles=3) + """ + + def __init__( + self, + timeout: float = 15.0, + device_id: Optional[str] = None, + ) -> None: + """Initialize the EL406 backend. + + Args: + timeout: Default timeout for operations in seconds. + device_id: FTDI device serial number for explicit connection. + """ + super().__init__() + self._device_id = device_id + self._command_lock: Optional[asyncio.Lock] = None + self._in_batch: bool = False + + # New architecture: driver + capability backends + self._new_driver = EL406Driver(timeout=timeout, device_id=device_id) + + self._plate_washing = EL406PlateWasher96Backend(self._new_driver) + self._shaking = EL406ShakingBackend(self._new_driver) + self._syringe = EL406SyringeDispensingBackend8(self._new_driver) + self._peristaltic = EL406PeristalticDispensingBackend8(self._new_driver) + + @property + def io(self): + return self._new_driver.io + + @io.setter + def io(self, value): + self._new_driver.io = value + + @property + def timeout(self) -> float: + return self._new_driver.timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._new_driver.timeout = value + + async def setup( + self, + skip_reset: bool = False, + ) -> None: + """Set up communication with the EL406. + + Args: + skip_reset: If True, skip the instrument reset step. + """ + # If io was injected (e.g. mock for testing), pass it to the driver + if self.io is not None: + self._new_driver.io = self.io + from pylabrobot.agilent.biotek.el406.driver import EL406Driver + + await self._new_driver.setup(backend_params=EL406Driver.SetupParams(skip_reset=skip_reset)) + # Sync back so legacy code can access io/lock + self.io = self._new_driver.io + self._command_lock = self._new_driver._command_lock + + logger.info("BioTekEL406Backend setup complete") + + async def stop(self) -> None: + """Stop communication with the EL406.""" + await self._new_driver.stop() + self.io = None + + @asynccontextmanager + async def batch(self, plate: Plate) -> AsyncIterator[None]: + """Context manager for batching step commands.""" + if self._in_batch: + yield + return + + self._new_driver._cached_plate = plate + self._in_batch = True + self._new_driver._in_batch = True + try: + await self._new_driver.start_batch(plate_to_wire_byte(plate)) + yield + finally: + try: + await self._new_driver.cleanup_after_protocol() + finally: + self._in_batch = False + self._new_driver._in_batch = False + + # Query mixin needs these — delegate to driver + async def _send_framed_query(self, command, data=b"", timeout=None): + return await self._new_driver._send_framed_query(command, data=data, timeout=timeout) + + async def _send_framed_command(self, framed_message, timeout=None): + return await self._new_driver._send_framed_command(framed_message, timeout=timeout) + + async def _test_communication(self): + return await self._new_driver._test_communication() + + # --------------------------------------------------------------------------- + # Queries — delegate to driver + # --------------------------------------------------------------------------- + + async def request_washer_manifold(self): + return await self._new_driver.request_washer_manifold() + + async def request_syringe_manifold(self): + return await self._new_driver.request_syringe_manifold() + + async def request_serial_number(self): + return await self._new_driver.request_serial_number() + + async def request_sensor_enabled(self, sensor): + return await self._new_driver.request_sensor_enabled(sensor) + + async def request_syringe_box_info(self): + return await self._new_driver.request_syringe_box_info() + + async def request_peristaltic_installed(self, selector): + return await self._new_driver.request_peristaltic_installed(selector) + + async def request_instrument_settings(self): + return await self._new_driver.request_instrument_settings() + + async def run_self_check(self): + return await self._new_driver.run_self_check() + + # --------------------------------------------------------------------------- + # Device-level operations — delegate to driver + # --------------------------------------------------------------------------- + + async def abort(self, step_type=None): + await self._new_driver.abort(step_type=step_type) + + async def pause(self): + await self._new_driver.pause() + + async def resume(self): + await self._new_driver.resume() + + async def reset(self): + await self._new_driver.reset() + + async def home_motors(self, home_type, motor=None): + await self._new_driver.home_motors(home_type, motor=motor) + + async def set_washer_manifold(self, manifold): + await self._new_driver.set_washer_manifold(manifold) + + # --------------------------------------------------------------------------- + # Manifold methods — delegate to new EL406PlateWasher96Backend + # --------------------------------------------------------------------------- + + async def manifold_aspirate( + self, + plate: Plate, + vacuum_filtration: bool = False, + travel_rate: str = "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: + async with self.batch(plate): + await self._plate_washing.aspirate( + plate, + backend_params=EL406PlateWasher96Backend.AspirateParams( + 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, + ), + ) + + async def manifold_dispense( + self, + plate: Plate, + volume: float, + buffer: str = "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: + async with self.batch(plate): + await self._plate_washing.dispense( + plate, + volume=volume, + backend_params=EL406PlateWasher96Backend.DispenseParams( + 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, + ), + ) + + async def manifold_wash( + self, + plate: Plate, + cycles: int = 3, + dispense_volume: Optional[float] = None, + buffer: str = "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: str = "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: str = "Plate", + sectors: Optional[list[int]] = None, + move_home_first: bool = False, + ) -> None: + async with self.batch(plate): + await self._plate_washing.wash( + plate, cycles=cycles, dispense_volume=dispense_volume, + backend_params=EL406PlateWasher96Backend.WashParams( + 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=final_aspirate, final_aspirate_z=final_aspirate_z, + 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, move_home_first=move_home_first, + ), + ) + + async def manifold_prime( + self, + plate: Plate, + volume: float, + buffer: str = "A", + flow_rate: int = 9, + low_flow_volume: float = 5000.0, + submerge_duration: float = 0.0, + ) -> None: + async with self.batch(plate): + await self._plate_washing.prime( + plate=plate, + backend_params=EL406PlateWasher96Backend.PrimeParams( + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + low_flow_volume=low_flow_volume, + submerge_duration=submerge_duration, + ), + ) + + async def manifold_auto_clean( + self, + plate: Plate, + buffer: str = "A", + duration: float = 60.0, + ) -> None: + async with self.batch(plate): + await self._plate_washing.auto_clean(plate, buffer=buffer, duration=duration) + + # --------------------------------------------------------------------------- + # Shake — delegate to new EL406ShakingBackend + # --------------------------------------------------------------------------- + + async def shake( + self, + plate: Plate, + duration: int = 0, + intensity: str = "Medium", + soak_duration: int = 0, + move_home_first: bool = True, + ) -> None: + async with self.batch(plate): + await self._shaking.shake( + speed=0, + duration=duration, + backend_params=EL406ShakingBackend.ShakeParams( + intensity=intensity, + soak_duration=soak_duration, + move_home_first=move_home_first, + ), + ) + + # --------------------------------------------------------------------------- + # Syringe — delegate to new EL406SyringeDispensingBackend8 + # --------------------------------------------------------------------------- + + async def syringe_dispense( + self, + plate: Plate, + volume: float, + syringe: Syringe = "A", + flow_rate: int = 2, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pump_delay: float = 0.0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + columns: Optional[list[int]] = None, + ) -> None: + async with self.batch(plate): + params = EL406SyringeDispensingBackend8.DispenseParams( + syringe=syringe, + flow_rate=flow_rate, + offset_x=offset_x / 10, # legacy 0.1mm → mm + offset_y=offset_y / 10, + offset_z=offset_z / 10, + pump_delay=pump_delay, + pre_dispense=pre_dispense, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + ) + await self._syringe._syringe_dispense(plate, volume=volume, columns=columns, params=params) + + async def syringe_prime( + self, + plate: Plate, + syringe: Literal["A", "B"] = "A", + volume: float = 5000.0, + flow_rate: int = 5, + refills: int = 2, + pump_delay: float = 0.0, + submerge_tips: bool = True, + submerge_duration: float = 0.0, + ) -> None: + async with self.batch(plate): + params = EL406SyringeDispensingBackend8.PrimeParams( + syringe=syringe, + flow_rate=flow_rate, + refills=refills, + pump_delay=pump_delay, + submerge_tips=submerge_tips, + submerge_duration=submerge_duration, + ) + await self._syringe.prime(plate, volume=volume, backend_params=params) + + # --------------------------------------------------------------------------- + # Peristaltic — delegate to new EL406PeristalticDispensingBackend8 + # --------------------------------------------------------------------------- + + async def peristaltic_prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + flow_rate: PeristalticFlowRate = "High", + cassette: Cassette = "Any", + ) -> None: + async with self.batch(plate): + params = EL406PeristalticDispensingBackend8.PrimeParams( + flow_rate=flow_rate, + cassette=cassette, + ) + await self._peristaltic.prime( + plate, + volume=volume, + duration=duration, + backend_params=params, + ) + + async def peristaltic_dispense( + self, + plate: Plate, + volume: float, + flow_rate: PeristalticFlowRate = "High", + offset_x: int = 0, + offset_y: int = 0, + offset_z: Optional[int] = None, + pre_dispense_volume: float = 10.0, + num_pre_dispenses: int = 2, + cassette: Cassette = "Any", + columns: Optional[list[int]] = None, + rows: Optional[list[int]] = None, + ) -> None: + async with self.batch(plate): + params = EL406PeristalticDispensingBackend8.DispenseParams( + flow_rate=flow_rate, + offset_x=offset_x / 10 if offset_x is not None else 0.0, # legacy 0.1mm → mm + offset_y=offset_y / 10 if offset_y is not None else 0.0, + offset_z=offset_z / 10 if offset_z is not None else None, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + cassette=cassette, + rows=rows, + ) + await self._peristaltic._peristaltic_dispense( + plate, volume=volume, columns=columns, params=params + ) + + async def peristaltic_purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + flow_rate: PeristalticFlowRate = "High", + cassette: Cassette = "Any", + ) -> None: + async with self.batch(plate): + params = EL406PeristalticDispensingBackend8.PrimeParams( + flow_rate=flow_rate, + cassette=cassette, + ) + await self._peristaltic.purge( + plate, + volume=volume, + duration=duration, + backend_params=params, + ) + + def serialize(self) -> dict: + """Serialize backend configuration.""" + return { + **super().serialize(), + "timeout": self.timeout, + "device_id": self._device_id, + } 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/legacy/plate_washing/biotek/el406/communication.py b/pylabrobot/legacy/plate_washing/biotek/el406/communication.py new file mode 100644 index 00000000000..a25f6749e97 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/communication.py @@ -0,0 +1,12 @@ +"""EL406 low-level communication methods — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.driver. +""" + +from pylabrobot.agilent.biotek.el406.driver import ( # noqa: F401 + LONG_READ_TIMEOUT, + DevicePollResult, +) +from pylabrobot.agilent.biotek.el406.driver import ( # noqa: F401 + EL406Driver as EL406CommunicationMixin, +) 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..f0f990b14bc --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/enums.py @@ -0,0 +1,13 @@ +"""EL406 enumeration types — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.enums. +""" + +from pylabrobot.agilent.biotek.el406.enums import ( # noqa: F401 + EL406Motor, + EL406MotorHomeType, + EL406Sensor, + EL406StepType, + EL406SyringeManifold, + EL406WasherManifold, +) 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..2b784d80669 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py @@ -0,0 +1,9 @@ +"""BioTek EL406 Error Codes — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.error_codes. +""" + +from pylabrobot.agilent.biotek.el406.error_codes import ( # noqa: F401 + ERROR_CODES, + get_error_message, +) 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..0f9b23ba96a --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/errors.py @@ -0,0 +1,9 @@ +"""EL406 exception classes — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.errors. +""" + +from pylabrobot.agilent.biotek.el406.errors import ( # noqa: F401 + EL406CommunicationError, + EL406DeviceError, +) 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..84cb91599ec --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py @@ -0,0 +1,13 @@ +"""EL406 plate type defaults and helper functions — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.helpers. +""" + +from pylabrobot.agilent.biotek.el406.helpers import ( # noqa: F401 + plate_default_z, + plate_defaults, + plate_max_columns, + plate_max_row_groups, + plate_to_wire_byte, + plate_well_count, +) diff --git a/pylabrobot/plate_washing/biotek/el406/helpers_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py similarity index 92% rename from pylabrobot/plate_washing/biotek/el406/helpers_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py index 9649c3eb73b..fb2f08edabf 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): @@ -41,7 +41,7 @@ def setUp(self): def test_encode_volume_little_endian(self): """Volume should be encoded as little-endian 2 bytes.""" - cmd = self.backend._build_dispense_command( + cmd = self.backend._plate_washing._build_dispense_command( plate=PT96, volume=1000.0, buffer="A", @@ -55,7 +55,7 @@ def test_encode_volume_little_endian(self): def test_encode_signed_byte_positive(self): """Positive offset should encode correctly.""" - cmd = self.backend._build_aspirate_command( + cmd = self.backend._plate_washing._build_aspirate_command( plate=PT96, time_value=1000, travel_rate_byte=3, @@ -68,7 +68,7 @@ def test_encode_signed_byte_positive(self): def test_encode_signed_byte_negative(self): """Negative offset should encode as two's complement.""" - cmd = self.backend._build_aspirate_command( + cmd = self.backend._plate_washing._build_aspirate_command( plate=PT96, time_value=1000, travel_rate_byte=3, 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..46873fc8928 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py @@ -0,0 +1,10 @@ +"""EL406 protocol framing utilities — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.protocol. +""" + +from pylabrobot.agilent.biotek.el406.protocol import ( # noqa: F401 + build_framed_message, + columns_to_column_mask, + encode_column_mask, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/queries.py b/pylabrobot/legacy/plate_washing/biotek/el406/queries.py new file mode 100644 index 00000000000..ba61be1fdfa --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/queries.py @@ -0,0 +1,10 @@ +"""EL406 query methods — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.driver. +""" + +from pylabrobot.agilent.biotek.el406.driver import EL406Driver + +InstrumentSettings = EL406Driver.InstrumentSettings +SelfCheckResult = EL406Driver.SelfCheckResult +SyringeBoxInfo = EL406Driver.SyringeBoxInfo 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/legacy/plate_washing/biotek/el406/steps/__init__.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py new file mode 100644 index 00000000000..9f86bc222cb --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py @@ -0,0 +1,9 @@ +"""EL406 protocol step methods — legacy wrapper. + +All step methods have been migrated to the capability architecture. +This module is kept for backward compatibility. +""" + + +class EL406StepsMixin: + """All step methods have been migrated to capability backends.""" diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py new file mode 100644 index 00000000000..147c4d0415d --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py @@ -0,0 +1,4 @@ +"""EL406 manifold step methods — legacy wrapper. + +Implementation has moved to pylabrobot.agilent.biotek.el406.plate_washing_backend. +""" diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py new file mode 100644 index 00000000000..9c27722df3f --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py @@ -0,0 +1,4 @@ +"""EL406 peristaltic pump step methods — legacy wrapper. + +Implementation has moved to pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend8. +""" diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py new file mode 100644 index 00000000000..01a95fb2b9a --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py @@ -0,0 +1,10 @@ +"""EL406 shake/soak step methods — legacy wrapper. + +Implementation has moved to pylabrobot.agilent.biotek.el406.shaking_backend. +""" + +from pylabrobot.agilent.biotek.el406.shaking_backend import ( # noqa: F401 + INTENSITY_TO_BYTE, + Intensity, + validate_intensity, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py new file mode 100644 index 00000000000..73e316e95fc --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py @@ -0,0 +1,4 @@ +"""EL406 syringe pump step methods — legacy wrapper. + +Implementation has moved to pylabrobot.agilent.biotek.el406.syringe_dispensing_backend8. +""" 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 86% 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..5843e19aed1 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): @@ -95,7 +95,7 @@ def setUp(self): def test_aspirate_command_defaults(self): """Default aspirate: no vacuum, rate 3, delay 0, z=30.""" - cmd = self.backend._build_aspirate_command(PT96) + cmd = self.backend._plate_washing._build_aspirate_command(PT96) self.assertEqual(len(cmd), 22) self.assertEqual(cmd[0], 0x04) self.assertEqual(cmd[1], 0) # no vacuum @@ -119,7 +119,9 @@ def test_aspirate_command_defaults(self): def test_aspirate_command_vacuum_filtration(self): """Vacuum filtration flag should be set when enabled.""" - cmd = self.backend._build_aspirate_command(PT96, vacuum_filtration=True, time_value=30) + cmd = self.backend._plate_washing._build_aspirate_command( + PT96, vacuum_filtration=True, time_value=30 + ) self.assertEqual(cmd[1], 1) # time_value=30 at bytes 2-3 self.assertEqual(cmd[2], 30) @@ -127,7 +129,7 @@ def test_aspirate_command_vacuum_filtration(self): def test_aspirate_command_delay_encoding(self): """Delay value should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, time_value=5000) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, time_value=5000) # 5000 = 0x1388 self.assertEqual(cmd[2], 0x88) self.assertEqual(cmd[3], 0x13) @@ -135,37 +137,37 @@ def test_aspirate_command_delay_encoding(self): def test_aspirate_command_travel_rate(self): """Travel rate should be encoded correctly.""" # Normal rate "5" -> byte 5 - cmd = self.backend._build_aspirate_command(PT96, travel_rate_byte=5) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, travel_rate_byte=5) self.assertEqual(cmd[4], 5) # CW rate "2 CW" -> byte 8 - cmd = self.backend._build_aspirate_command(PT96, travel_rate_byte=8) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, travel_rate_byte=8) self.assertEqual(cmd[4], 8) def test_aspirate_command_negative_offset_x(self): """Negative X offset should be encoded as unsigned byte.""" - cmd = self.backend._build_aspirate_command(PT96, offset_x=-30) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, offset_x=-30) # -30 as unsigned byte = 226 = 0xE2 self.assertEqual(cmd[5], 226) def test_aspirate_command_positive_offset_y(self): """Positive Y offset should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, offset_y=5) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, offset_y=5) self.assertEqual(cmd[6], 5) def test_aspirate_command_z_offset(self): """Z offset should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, offset_z=121) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, offset_z=121) self.assertEqual(cmd[7], 121) self.assertEqual(cmd[8], 0) def test_aspirate_command_secondary_mode(self): """Secondary mode should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, secondary_mode=1) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, secondary_mode=1) self.assertEqual(cmd[9], 1) def test_aspirate_command_secondary_offsets(self): """Secondary offsets should be encoded correctly.""" - cmd = self.backend._build_aspirate_command( + cmd = self.backend._plate_washing._build_aspirate_command( PT96, secondary_x=-5, secondary_y=3, @@ -179,13 +181,13 @@ def test_aspirate_command_secondary_offsets(self): def test_aspirate_command_column_mask_all(self): """Column mask should select all columns for manifold aspirate.""" - cmd = self.backend._build_aspirate_command(PT96) + cmd = self.backend._plate_washing._build_aspirate_command(PT96) self.assertEqual(cmd[16], 0xFF) # all 12 columns self.assertEqual(cmd[17], 0x0F) def test_aspirate_command_length(self): """Aspirate command should be exactly 22 bytes.""" - cmd = self.backend._build_aspirate_command(PT96) + cmd = self.backend._plate_washing._build_aspirate_command(PT96) self.assertEqual(len(cmd), 22) 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 90% 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..2686ee9a79c 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): @@ -81,7 +81,7 @@ def setUp(self): def test_peristaltic_dispense_step_type(self): """Peristaltic dispense command should have step type prefix 0x04.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -91,7 +91,7 @@ def test_peristaltic_dispense_step_type(self): def test_peristaltic_dispense_volume_encoding(self): """Peristaltic dispense should encode volume as little-endian 2 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -103,7 +103,7 @@ def test_peristaltic_dispense_volume_encoding(self): def test_peristaltic_dispense_volume_1000ul(self): """Peristaltic dispense with 1000 uL.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=1000.0, flow_rate=5, @@ -115,7 +115,7 @@ def test_peristaltic_dispense_volume_1000ul(self): def test_peristaltic_dispense_flow_rate_at_byte3(self): """Peristaltic dispense flow rate should be at byte 3.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -125,7 +125,7 @@ def test_peristaltic_dispense_flow_rate_at_byte3(self): def test_peristaltic_dispense_cassette_at_byte4(self): """Peristaltic dispense cassette type should be at byte 4.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -137,7 +137,7 @@ def test_peristaltic_dispense_cassette_at_byte4(self): def test_peristaltic_dispense_offset_z(self): """Peristaltic dispense should encode Z offset as little-endian 2 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -150,7 +150,7 @@ def test_peristaltic_dispense_offset_z(self): def test_peristaltic_dispense_offset_x_positive(self): """Peristaltic dispense should encode positive X offset at byte 5.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -161,7 +161,7 @@ def test_peristaltic_dispense_offset_x_positive(self): def test_peristaltic_dispense_offset_x_negative(self): """Peristaltic dispense should encode negative X offset as two's complement at byte 5.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -173,7 +173,7 @@ def test_peristaltic_dispense_offset_x_negative(self): def test_peristaltic_dispense_offset_y_negative(self): """Peristaltic dispense should encode negative Y offset as two's complement at byte 6.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -185,7 +185,7 @@ def test_peristaltic_dispense_offset_y_negative(self): def test_peristaltic_dispense_pre_dispense_volume(self): """Peristaltic dispense should encode prime volume as little-endian 2 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -198,7 +198,7 @@ def test_peristaltic_dispense_pre_dispense_volume(self): def test_peristaltic_dispense_num_pre_dispenses_default(self): """Peristaltic dispense should encode default num_pre_dispenses (2) at byte 11.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=7, @@ -209,7 +209,7 @@ def test_peristaltic_dispense_num_pre_dispenses_default(self): def test_peristaltic_dispense_num_pre_dispenses_1(self): """Peristaltic dispense should encode num_pre_dispenses=1 at byte 11.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=1, @@ -220,7 +220,7 @@ def test_peristaltic_dispense_num_pre_dispenses_1(self): def test_peristaltic_dispense_num_pre_dispenses_5(self): """Peristaltic dispense should encode num_pre_dispenses=5 at byte 11.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=9, @@ -231,7 +231,7 @@ def test_peristaltic_dispense_num_pre_dispenses_5(self): def test_peristaltic_dispense_full_command(self): """Test complete peristaltic dispense command with all parameters.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=500.0, flow_rate=7, @@ -352,7 +352,7 @@ def setUp(self): def test_peristaltic_dispense_command_with_column_mask_length(self): """Command with well mask should be 24 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -362,7 +362,7 @@ def test_peristaltic_dispense_command_with_column_mask_length(self): def test_peristaltic_dispense_command_column_mask_encoding(self): """Command should correctly encode well mask at bytes 12-17.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -375,7 +375,7 @@ def test_peristaltic_dispense_command_column_mask_encoding(self): def test_peristaltic_dispense_command_pump_at_byte19(self): """Pump should be at byte 19 (1=Primary, 2=Secondary).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -385,7 +385,7 @@ def test_peristaltic_dispense_command_pump_at_byte19(self): def test_peristaltic_dispense_command_none_column_mask_all_wells(self): """Command with None column_mask should encode all wells (0xFF * 6).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -395,7 +395,7 @@ def test_peristaltic_dispense_command_none_column_mask_all_wells(self): def test_peristaltic_dispense_command_default_row_mask(self): """Default rows=None should encode 0x00 (all selected, inverted).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -404,7 +404,7 @@ def test_peristaltic_dispense_command_default_row_mask(self): def test_peristaltic_dispense_command_default_pump(self): """Default pump should be 1 (Primary).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -413,7 +413,7 @@ def test_peristaltic_dispense_command_default_pump(self): def test_peristaltic_dispense_command_empty_column_mask(self): """Command with empty column_mask should encode no wells (0x00 * 6).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -425,7 +425,7 @@ def test_peristaltic_dispense_command_rows_inverted_encoding(self): """Row mask uses inverted encoding: 0=selected, 1=deselected.""" # Use 1536-well plate type which supports 4 row groups # Select rows 1 and 2 -> bits 0,1 cleared, bits 2,3 set -> 0x0C - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT1536, volume=300.0, flow_rate=5, @@ -436,7 +436,7 @@ def test_peristaltic_dispense_command_rows_inverted_encoding(self): def test_peristaltic_dispense_command_complex_column_mask(self): """Command with complex well mask spanning multiple bytes.""" # Wells 0, 8, 16, 24, 32, 40 = bit 0 of each of the 6 bytes - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -453,7 +453,7 @@ def test_peristaltic_dispense_command_complex_column_mask(self): def test_peristaltic_dispense_command_both_masks(self): """Command with column_mask and rows.""" # Use 1536-well plate type which supports 4 row groups - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT1536, volume=500.0, flow_rate=7, 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 89% 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..8a6c14bc7a8 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): @@ -191,7 +191,7 @@ def setUp(self): def test_syringe_prime_step_type(self): """Syringe prime command should have prefix 0x04.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -201,7 +201,7 @@ def test_syringe_prime_step_type(self): def test_syringe_prime_syringe_a(self): """Syringe prime syringe A should encode as 0.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -211,7 +211,7 @@ def test_syringe_prime_syringe_a(self): def test_syringe_prime_syringe_b(self): """Syringe prime syringe B should encode as 1.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="B", @@ -221,7 +221,7 @@ def test_syringe_prime_syringe_b(self): def test_syringe_prime_lowercase_syringe(self): """Syringe prime should accept lowercase syringe names.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="b", @@ -231,7 +231,7 @@ def test_syringe_prime_lowercase_syringe(self): def test_syringe_prime_volume_encoding(self): """Syringe prime should encode volume as little-endian 2 bytes.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -242,7 +242,7 @@ def test_syringe_prime_volume_encoding(self): def test_syringe_prime_volume_1000ul(self): """Syringe prime with 1000 uL.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -254,7 +254,7 @@ def test_syringe_prime_volume_1000ul(self): def test_syringe_prime_flow_rate(self): """Syringe prime should encode flow rate as single byte.""" for rate in [1, 3, 5]: - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -264,7 +264,7 @@ def test_syringe_prime_flow_rate(self): def test_syringe_prime_refills(self): """Syringe prime should encode refills as single byte.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -275,7 +275,7 @@ def test_syringe_prime_refills(self): def test_syringe_prime_default_refills(self): """Syringe prime should default to 2 refills.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -285,7 +285,7 @@ def test_syringe_prime_default_refills(self): def test_syringe_prime_pump_delay(self): """Syringe prime should encode pump delay as LE uint16.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -298,7 +298,7 @@ def test_syringe_prime_pump_delay(self): def test_syringe_prime_command_length(self): """Syringe prime command should have exactly 13 bytes.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -308,7 +308,7 @@ def test_syringe_prime_command_length(self): def test_syringe_prime_full_command(self): """Test complete syringe prime command with all parameters.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=3000.0, syringe="B", @@ -336,13 +336,13 @@ def test_syringe_prime_full_command(self): def test_syringe_prime_bottle_encoding(self): """Test syringe prime encodes bottle from syringe selection.""" - cmd_a = self.backend._build_syringe_prime_command( + cmd_a = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", flow_rate=5, ) - cmd_b = self.backend._build_syringe_prime_command( + cmd_b = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="B", @@ -353,7 +353,7 @@ def test_syringe_prime_bottle_encoding(self): def test_syringe_prime_submerge_duration(self): """Test syringe prime encodes submerge duration at bytes 9-10.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -368,7 +368,7 @@ def test_syringe_prime_submerge_duration(self): def test_syringe_prime_submerge_disabled_zeroes_time(self): """When submerge_tips=False, time bytes should be zero.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -382,7 +382,7 @@ def test_syringe_prime_submerge_disabled_zeroes_time(self): def test_syringe_prime_submerge_max_duration(self): """Test max submerge duration (1439 minutes = 23:59).""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -473,7 +473,7 @@ def setUp(self): def test_manifold_prime_step_type(self): """Manifold prime command should have step type prefix 0x04.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -484,7 +484,7 @@ def test_manifold_prime_step_type(self): def test_manifold_prime_buffer_a(self): """Manifold prime buffer A should encode as 'A' (0x41).""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -495,7 +495,7 @@ def test_manifold_prime_buffer_a(self): def test_manifold_prime_buffer_b(self): """Manifold prime buffer B should encode as 'B' (0x42).""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="B", @@ -506,7 +506,7 @@ def test_manifold_prime_buffer_b(self): def test_manifold_prime_lowercase_buffer(self): """Manifold prime should accept lowercase buffer and encode as uppercase.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="b", @@ -517,7 +517,7 @@ def test_manifold_prime_lowercase_buffer(self): def test_manifold_prime_volume_encoding(self): """Manifold prime should encode volume as little-endian 2 bytes.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -530,7 +530,7 @@ def test_manifold_prime_volume_encoding(self): def test_manifold_prime_volume_500ml(self): """Manifold prime with 500 mL.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=500.0, buffer="A", @@ -543,7 +543,7 @@ def test_manifold_prime_volume_500ml(self): def test_manifold_prime_volume_max(self): """Manifold prime with maximum volume (65535 mL).""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=65535.0, buffer="A", @@ -555,7 +555,7 @@ def test_manifold_prime_volume_max(self): def test_manifold_prime_flow_rate(self): """Manifold prime should encode flow rate as single byte.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -566,7 +566,7 @@ def test_manifold_prime_flow_rate(self): def test_manifold_prime_flow_rate_min(self): """Manifold prime should encode minimum flow rate 1.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -577,7 +577,7 @@ def test_manifold_prime_flow_rate_min(self): def test_manifold_prime_flow_rate_max(self): """Manifold prime should encode maximum flow rate 9.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -588,7 +588,7 @@ def test_manifold_prime_flow_rate_max(self): def test_manifold_prime_full_command(self): """Test complete manifold prime command with all parameters.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_prime_command( PT96, volume_ml=2000.0, buffer="B", @@ -663,31 +663,31 @@ def setUp(self): def test_auto_clean_step_type(self): """Auto-clean command should have step type prefix 0x04.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A") self.assertEqual(cmd[0], 0x04) def test_auto_clean_buffer_a(self): """Auto-clean buffer A should encode as 'A' (0x41).""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A") self.assertEqual(cmd[1], ord("A")) def test_auto_clean_buffer_b(self): """Auto-clean buffer B should encode as 'B' (0x42).""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="B") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="B") self.assertEqual(cmd[1], ord("B")) def test_auto_clean_lowercase_buffer(self): """Auto-clean should accept lowercase buffer and encode as uppercase.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="c") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="c") self.assertEqual(cmd[1], ord("C")) def test_auto_clean_duration_encoding(self): """Auto-clean should encode duration as little-endian 2 bytes.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A", duration_min=60.0) + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A", duration_min=60.0) # 60 = 0x003C LE self.assertEqual(cmd[2], 0x3C) @@ -695,7 +695,7 @@ def test_auto_clean_duration_encoding(self): def test_auto_clean_duration_30_minutes(self): """Auto-clean with 30 minute duration.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A", duration_min=30.0) + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A", duration_min=30.0) # 30 = 0x001E LE self.assertEqual(cmd[2], 0x1E) @@ -703,14 +703,14 @@ def test_auto_clean_duration_30_minutes(self): def test_auto_clean_duration_zero(self): """Auto-clean with zero duration (no additional cleaning time).""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A", duration_min=0.0) + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A", duration_min=0.0) self.assertEqual(cmd[2], 0x00) self.assertEqual(cmd[3], 0x00) def test_auto_clean_full_command(self): """Test complete auto-clean command with all parameters.""" - cmd = self.backend._build_auto_clean_command( + cmd = self.backend._plate_washing._build_auto_clean_command( PT96, buffer="B", duration_min=90.0, @@ -723,7 +723,7 @@ def test_auto_clean_full_command(self): def test_auto_clean_default_duration(self): """Auto-clean without duration should use default 1 minute.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A") self.assertEqual(cmd[2], 0x01) self.assertEqual(cmd[3], 0x00) 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 89% 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..cbb6e11f94e 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): @@ -70,7 +70,7 @@ def setUp(self): def test_shake_command_basic(self): """Basic shake: 10 seconds, medium intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=10.0, soak_duration=0.0, @@ -90,7 +90,7 @@ def test_shake_command_basic(self): def test_shake_command_variable_intensity(self): """Variable intensity encoding.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -109,7 +109,7 @@ def test_shake_command_encoding_durations(self): ] for duration, expected_hex in cases: with self.subTest(duration=duration): - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=duration, soak_duration=0.0, @@ -121,7 +121,7 @@ def test_shake_command_encoding_durations(self): def test_shake_command_encoding_shake_disabled(self): """Shake disabled should zero the duration.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -135,7 +135,7 @@ def test_shake_command_encoding_shake_disabled(self): def test_shake_command_encoding_move_home_false(self): """Verify encoding with move_home_first=false.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -149,7 +149,7 @@ def test_shake_command_encoding_move_home_false(self): def test_shake_command_encoding_soak_30s(self): """Verify encoding with 30s soak duration.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=30.0, @@ -163,7 +163,7 @@ def test_shake_command_encoding_soak_30s(self): def test_shake_command_encoding_soak_60s(self): """Verify encoding with 60s soak duration.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=60.0, @@ -177,7 +177,7 @@ def test_shake_command_encoding_soak_60s(self): def test_shake_command_encoding_slow_frequency(self): """Verify encoding with slow intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -191,7 +191,7 @@ def test_shake_command_encoding_slow_frequency(self): def test_shake_command_encoding_fast_frequency(self): """Verify encoding with fast intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -205,7 +205,7 @@ def test_shake_command_encoding_fast_frequency(self): def test_shake_command_encoding_complex(self): """Verify encoding with combined shake, soak, and slow intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=300.0, soak_duration=120.0, @@ -219,7 +219,7 @@ def test_shake_command_encoding_complex(self): def test_shake_command_encoding_move_home_false_with_soak(self): """Verify encoding with move_home_first=false and soak.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=60.0, @@ -233,7 +233,7 @@ def test_shake_command_encoding_move_home_false_with_soak(self): def test_shake_command_max_duration_encoding(self): """Verify encoding with maximum duration (3599s).""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=3599, soak_duration=3599, 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 87% 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..0b43058d7ed 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, @@ -153,12 +153,14 @@ def setUp(self): def test_composite_command_length(self): """Composite wash command should produce the expected payload length.""" - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(len(cmd), 102) def test_composite_command_aspirate_sections(self): """Aspirate sections should encode travel rate and Z offsets.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_travel_rate=5, aspirate_z=40) + cmd = self.backend._plate_washing._build_wash_composite_command( + PT96, aspirate_travel_rate=5, aspirate_z=40 + ) # Aspirate section 1 (final aspirate, mirrors primary Z) self.assertEqual(cmd[29], 5) # travel rate (propagated) self.assertEqual(cmd[32], 0x28) # Z low (40) @@ -174,21 +176,21 @@ def test_composite_command_aspirate_sections(self): def test_composite_command_final_section(self): """Final section should have shake intensity at the expected position.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_travel_rate=3) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_travel_rate=3) self.assertEqual(cmd[90], 3) # shake intensity (default Medium=3) self.assertEqual(cmd[91], 0x00) def test_composite_command_final_aspirate_flag(self): """Final aspirate flag should be encoded in the header.""" - cmd_on = self.backend._build_wash_composite_command(PT96, final_aspirate=True) + cmd_on = self.backend._plate_washing._build_wash_composite_command(PT96, final_aspirate=True) self.assertEqual(cmd_on[2], 0x01) - cmd_off = self.backend._build_wash_composite_command(PT96, final_aspirate=False) + cmd_off = self.backend._plate_washing._build_wash_composite_command(PT96, final_aspirate=False) self.assertEqual(cmd_off[2], 0x00) def test_composite_command_pre_dispense_volume(self): """Pre-dispense volume should be encoded in both dispense sections.""" - cmd = self.backend._build_wash_composite_command(PT96, pre_dispense_volume=100.0) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, pre_dispense_volume=100.0) # Dispense1 self.assertEqual(cmd[15], 0x64) # 100 low self.assertEqual(cmd[16], 0x00) # 100 high @@ -198,7 +200,7 @@ def test_composite_command_pre_dispense_volume(self): def test_composite_command_vacuum_delay_volume(self): """Vacuum delay volume should be encoded in both dispense sections.""" - cmd = self.backend._build_wash_composite_command(PT96, vacuum_delay_volume=200.0) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, vacuum_delay_volume=200.0) # Dispense1 self.assertEqual(cmd[18], 0xC8) # 200 low self.assertEqual(cmd[19], 0x00) # 200 high @@ -208,13 +210,15 @@ def test_composite_command_vacuum_delay_volume(self): def test_composite_command_aspirate_delay(self): """Final aspirate section should always have delay=0.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_delay_ms=1000) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_delay_ms=1000) self.assertEqual(cmd[30], 0x00) self.assertEqual(cmd[31], 0x00) def test_composite_command_aspirate_offsets(self): """Aspirate X/Y offsets should only appear in the primary aspirate section.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_x=15, aspirate_y=-10) + cmd = self.backend._plate_washing._build_wash_composite_command( + PT96, aspirate_x=15, aspirate_y=-10 + ) # Final aspirate: X/Y fixed at 0 self.assertEqual(cmd[34], 0x00) self.assertEqual(cmd[35], 0x00) @@ -224,47 +228,49 @@ def test_composite_command_aspirate_offsets(self): def test_composite_command_shake_duration(self): """Shake duration should be encoded correctly.""" - cmd = self.backend._build_wash_composite_command(PT96, shake_duration=30) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, shake_duration=30) self.assertEqual(cmd[88], 30) self.assertEqual(cmd[89], 0x00) def test_composite_command_shake_intensity(self): """Shake intensity should be encoded correctly for each level.""" - cmd_fast = self.backend._build_wash_composite_command( + cmd_fast = self.backend._plate_washing._build_wash_composite_command( PT96, shake_duration=10, shake_intensity="Fast" ) self.assertEqual(cmd_fast[90], 0x04) - cmd_slow = self.backend._build_wash_composite_command( + cmd_slow = self.backend._plate_washing._build_wash_composite_command( PT96, shake_duration=10, shake_intensity="Slow" ) self.assertEqual(cmd_slow[90], 0x02) - cmd_var = self.backend._build_wash_composite_command( + cmd_var = self.backend._plate_washing._build_wash_composite_command( PT96, shake_duration=10, shake_intensity="Variable" ) self.assertEqual(cmd_var[90], 0x01) def test_composite_command_shake_intensity_default_when_disabled(self): """Shake intensity should stay at default when shake_duration=0.""" - cmd = self.backend._build_wash_composite_command(PT96, shake_duration=0, shake_intensity="Fast") + cmd = self.backend._plate_washing._build_wash_composite_command( + PT96, shake_duration=0, shake_intensity="Fast" + ) self.assertEqual(cmd[90], 0x03) def test_composite_command_soak_duration(self): """Soak duration should be encoded correctly.""" - cmd = self.backend._build_wash_composite_command(PT96, soak_duration=90) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, soak_duration=90) self.assertEqual(cmd[92], 90) self.assertEqual(cmd[93], 0x00) def test_composite_command_soak_duration_large(self): """Large soak duration should encode correctly as 16-bit LE.""" - cmd = self.backend._build_wash_composite_command(PT96, soak_duration=3599) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, soak_duration=3599) self.assertEqual(cmd[92], 0x0F) self.assertEqual(cmd[93], 0x0E) def test_composite_command_all_new_params(self): """All new parameters set to non-default values should produce correct output.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, cycles=5, buffer="B", @@ -322,25 +328,25 @@ def setUp(self): def test_move_home_default_disabled(self): """move_home_first should default to False.""" - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[87], 0x00) def test_move_home_enabled(self): """move_home_first=True should set the move-home flag.""" - cmd = self.backend._build_wash_composite_command(PT96, move_home_first=True) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, move_home_first=True) self.assertEqual(cmd[87], 0x01) def test_move_home_does_not_affect_other_bytes(self): """Enabling move_home_first should only change one byte.""" - cmd_off = self.backend._build_wash_composite_command(PT96, move_home_first=False) - cmd_on = self.backend._build_wash_composite_command(PT96, move_home_first=True) + cmd_off = self.backend._plate_washing._build_wash_composite_command(PT96, move_home_first=False) + cmd_on = self.backend._plate_washing._build_wash_composite_command(PT96, move_home_first=True) # Only byte [87] should differ diffs = [i for i in range(102) if cmd_off[i] != cmd_on[i]] self.assertEqual(diffs, [87]) def test_move_home_with_shake_and_soak(self): """move_home_first should coexist with shake/soak parameters.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, move_home_first=True, shake_duration=15, shake_intensity="Fast", soak_duration=45 ) self.assertEqual(cmd[87], 0x01) # move_home @@ -359,7 +365,7 @@ def setUp(self): def test_secondary_aspirate_disabled_default(self): """Secondary aspirate offsets should use defaults when disabled.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_z=40) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_z=40) # Final aspirate: sec_z mirrors final_asp_z self.assertEqual(cmd[37], 0x28) # secondary Z = 40 self.assertEqual(cmd[38], 0x00) @@ -371,7 +377,7 @@ def test_secondary_aspirate_disabled_default(self): def test_secondary_aspirate_enabled(self): """When secondary_aspirate=True, primary aspirate gets secondary Z and mode enabled.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, aspirate_z=40, secondary_aspirate=True, secondary_z=100 ) # Final aspirate: secondary mode stays off by default @@ -394,7 +400,7 @@ def setUp(self): def test_pre_dispense_flow_rate_encoding(self): """pre_dispense_flow_rate should encode at correct positions.""" - cmd = self.backend._build_wash_composite_command(PT96, pre_dispense_flow_rate=7) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, pre_dispense_flow_rate=7) self.assertEqual(cmd[17], 7) # Dispense1 self.assertEqual(cmd[78], 7) # Dispense2 @@ -407,7 +413,7 @@ def setUp(self): def test_secondary_xy_default_zero(self): """Secondary X/Y should default to 0 and not affect baseline output.""" - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) # Final aspirate: always fixed 0 self.assertEqual(cmd[40], 0x00) # secondary X self.assertEqual(cmd[41], 0x00) # secondary Y @@ -417,7 +423,7 @@ def test_secondary_xy_default_zero(self): def test_secondary_xy_encoded_when_enabled(self): """Secondary X/Y should be encoded in primary aspirate when secondary_aspirate=True.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, secondary_aspirate=True, secondary_x=15, secondary_y=-10, secondary_z=50 ) # Final aspirate: always fixed 0 @@ -429,7 +435,7 @@ def test_secondary_xy_encoded_when_enabled(self): def test_secondary_xy_zero_when_disabled(self): """Secondary X/Y should be 0 when secondary_aspirate=False, even if values set.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, secondary_aspirate=False, secondary_x=15, secondary_y=-10 ) self.assertEqual(cmd[40], 0x00) # final aspirate (always fixed) @@ -446,7 +452,7 @@ def setUp(self): def test_bottom_wash_disabled_dispense1_mirrors_main(self): """When bottom_wash=False, Dispense1 should mirror main dispense volume/flow.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, dispense_volume=500.0, dispense_flow_rate=5 ) # Dispense1 @@ -460,7 +466,7 @@ def test_bottom_wash_disabled_dispense1_mirrors_main(self): def test_bottom_wash_enabled_dispense1_uses_bottom_params(self): """When bottom_wash=True, Dispense1 should use bottom wash volume/flow.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, dispense_volume=300.0, dispense_flow_rate=7, @@ -512,7 +518,7 @@ def setUp(self): def test_midcyc_disabled_dispense2_uses_main_pre_dispense(self): """When midcyc volume=0, Dispense2 pre-dispense mirrors main pre-dispense.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, pre_dispense_volume=100.0, pre_dispense_flow_rate=7 ) # Dispense1 @@ -526,7 +532,7 @@ def test_midcyc_disabled_dispense2_uses_main_pre_dispense(self): def test_midcyc_enabled_dispense2_uses_midcyc_values(self): """When midcyc volume>0, Dispense2 pre-dispense uses midcyc values.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, pre_dispense_volume=100.0, pre_dispense_flow_rate=7, @@ -580,7 +586,7 @@ def test_baseline(self): "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd, expected) def test_aspirate_xyz_capture(self): @@ -590,7 +596,7 @@ def test_aspirate_xyz_capture(self): "00000000000000000000000003050a1c000000001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, aspirate_z=28, aspirate_x=5, aspirate_y=10 ) self.assertEqual(cmd, expected) @@ -602,7 +608,7 @@ def test_secondary_aspirate_capture(self): "0000000000000000000000000300001d000100001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command(PT96, secondary_aspirate=True) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, secondary_aspirate=True) self.assertEqual(cmd, expected) def test_final_secondary_aspirate_capture(self): @@ -614,7 +620,7 @@ def test_final_secondary_aspirate_capture(self): "412c0107000079000000090000000000000000000000" "030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, cycles=2, buffer="A", final_secondary_aspirate=True, final_secondary_z=40 ) self.assertEqual(cmd, expected) @@ -626,7 +632,7 @@ def test_bottom_wash_capture(self): "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, bottom_wash=True, bottom_wash_volume=200.0, bottom_wash_flow_rate=5 ) self.assertEqual(cmd, expected) @@ -638,7 +644,7 @@ def test_pre_dispense_between_cycles_capture(self): "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" "3200090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, pre_dispense_between_cycles_volume=50.0, pre_dispense_between_cycles_flow_rate=9 ) self.assertEqual(cmd, expected) @@ -652,7 +658,7 @@ def test_aspirate_delay_capture(self): "000000000000000000030000000000000000000000" ) expected = bytes.fromhex(capture_hex) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT384, cycles=3, sector_mask=0x0F, @@ -677,7 +683,7 @@ def test_p384_sector_plate_format_capture(self): "000000000000000000000003000016000000001600000000000000000041640007000078000000" "090000000000000000000000030000000000000000000000" ) - cmd0 = self.backend._build_wash_composite_command( + cmd0 = self.backend._plate_washing._build_wash_composite_command( PT384, cycles=1, sector_mask=0x0E, @@ -698,7 +704,7 @@ def test_p384_sector_plate_format_capture(self): "000000000000000000000003000016000000001600000000000000000041640007000078000000" "090000000000000000000000030000000000000000000000" ) - cmd2 = self.backend._build_wash_composite_command( + cmd2 = self.backend._plate_washing._build_wash_composite_command( PT384, cycles=1, sector_mask=0x0F, @@ -721,50 +727,50 @@ class TestWash384WellPlateSupport(unittest.TestCase): def test_384_well_plate_type_byte(self): """384-well backend should produce the correct plate type prefix.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT384) + cmd = backend._plate_washing._build_wash_composite_command(PT384) self.assertEqual(cmd[0], 0x01) def test_96_well_plate_type_byte(self): """96-well backend (default) should produce the correct plate type prefix.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[0], 0x04) def test_wash_format_plate_default(self): """Default wash_format='Plate' should encode as 0.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[3], 0x00) def test_wash_format_sector(self): """wash_format='Sector' should encode as 1.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, wash_format="Sector") + cmd = backend._plate_washing._build_wash_composite_command(PT96, wash_format="Sector") self.assertEqual(cmd[3], 0x01) def test_cycles_at_byte6(self): """cycles should be encoded at the expected position.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, cycles=5) + cmd = backend._plate_washing._build_wash_composite_command(PT96, cycles=5) self.assertEqual(cmd[6], 5) def test_cycles_default(self): """Default cycles=3 should be encoded correctly.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[6], 3) def test_sector_mask_le_encoding(self): """Sector mask should be encoded as 16-bit LE.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, sector_mask=0x0E) + cmd = backend._plate_washing._build_wash_composite_command(PT96, sector_mask=0x0E) self.assertEqual(cmd[4], 0x0E) self.assertEqual(cmd[5], 0x00) def test_384_well_full_combination(self): """384-well with Sector format and custom sector mask.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command( + cmd = backend._plate_washing._build_wash_composite_command( PT384, wash_format="Sector", cycles=1, sector_mask=0x0E, aspirate_travel_rate=3 ) self.assertEqual(cmd[0], 0x01) # plate type @@ -805,7 +811,7 @@ class TestWashPlateTypeDefaults(unittest.TestCase): def test_96_well_defaults(self): """96-well plate should use 96-well defaults (300uL, dispZ=121, aspZ=29).""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) # dispense_volume=300 self.assertEqual(cmd[8], 0x2C) self.assertEqual(cmd[9], 0x01) @@ -822,7 +828,7 @@ def test_96_well_defaults(self): def test_384_well_defaults(self): """384-well plate should use 384-well defaults (100uL, dispZ=120, aspZ=22).""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT384) + cmd = backend._plate_washing._build_wash_composite_command(PT384) self.assertEqual(cmd[0], 0x01) # plate type # dispense_volume=100 self.assertEqual(cmd[8], 0x64) @@ -843,7 +849,7 @@ def test_384_well_defaults(self): def test_384_pcr_defaults(self): """384 PCR plate should use its specific defaults (100uL, dispZ=83, aspZ=2).""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT384PCR) + cmd = backend._plate_washing._build_wash_composite_command(PT384PCR) self.assertEqual(cmd[0], 0x02) # plate type self.assertEqual(cmd[8], 0x64) # vol=100 low self.assertEqual(cmd[13], 0x53) # dispense_z=83 @@ -853,7 +859,7 @@ def test_384_pcr_defaults(self): def test_1536_well_defaults(self): """1536-well plate should use its specific defaults.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT1536) + cmd = backend._plate_washing._build_wash_composite_command(PT1536) self.assertEqual(cmd[0], 0x00) # plate type # dispense_volume=100 self.assertEqual(cmd[8], 0x64) @@ -868,7 +874,7 @@ def test_1536_well_defaults(self): def test_1536_flange_defaults(self): """1536 flange plate should use its specific defaults.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT1536F) + cmd = backend._plate_washing._build_wash_composite_command(PT1536F) self.assertEqual(cmd[0], 0x0E) # plate type # dispense_volume=100 self.assertEqual(cmd[8], 0x64) @@ -883,7 +889,7 @@ def test_1536_flange_defaults(self): def test_explicit_values_override_plate_defaults(self): """Explicit parameter values should override plate-type defaults.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command( + cmd = backend._plate_washing._build_wash_composite_command( PT384, dispense_volume=500.0, dispense_z=200, aspirate_z=50, secondary_z=30 ) # dispense_volume=500 overrides 100 @@ -899,7 +905,7 @@ def test_explicit_values_override_plate_defaults(self): def test_secondary_z_independent_of_aspirate_z(self): """secondary_z default should be plate-type default, NOT user aspirate_z.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, aspirate_z=40) + cmd = backend._plate_washing._build_wash_composite_command(PT96, aspirate_z=40) # aspirate_z=40 (user override) self.assertEqual(cmd[53], 0x28) # aspirate_z = 40 # secondary_z should still be 29 (plate-type default), NOT 40 @@ -917,7 +923,7 @@ def test_all_plate_types_produce_102_bytes(self): "test_1536_flange": 0x0E, } for plate in plate_types: - cmd = backend._build_wash_composite_command(plate) + cmd = backend._plate_washing._build_wash_composite_command(plate) self.assertEqual(len(cmd), 102, f"Wrong length for {plate.name}") self.assertEqual(cmd[0], expected_prefixes[plate.name], f"Wrong prefix for {plate.name}") 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 3e9b410a808..8f65821f506 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 Cor_96_wellplate_360ul_Fb, Powder 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/legacy/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py new file mode 100644 index 00000000000..997a0acf1c4 --- /dev/null +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py @@ -0,0 +1,72 @@ +"""Legacy. Use pylabrobot.agrowpumps instead.""" + +from typing import Dict, List, Union + +from pylabrobot.agrowpumps.agrowdosepump_backend import AgrowChannelBackend, AgrowDriver +from pylabrobot.legacy.pumps.backend import PumpArrayBackend + + +class AgrowPumpArrayBackend(PumpArrayBackend): + """Legacy. Use pylabrobot.agrowpumps.AgrowDosePumpArray instead.""" + + def __init__(self, port: str, address: Union[int, str]): + self.driver = AgrowDriver(port=port, address=address) + self._backends: List[AgrowChannelBackend] = [] + + @property + def port(self): + return self.driver.port + + @property + def address(self): + return self.driver.address + + @property + def modbus(self): + return self.driver.modbus + + @property + def num_channels(self) -> int: + return self.driver.num_channels + + @property + def pump_index_to_address(self) -> Dict[int, int]: + return self.driver.pump_index_to_address + + async def setup(self): + await self.driver.setup() + self._backends = [ + AgrowChannelBackend(self.driver, ch) for ch in range(self.driver.num_channels) + ] + + async def stop(self): + await self.halt() + await self.driver.stop() + + def serialize(self): + return { + **super().serialize(), + "port": self.port, + "address": self.address, + } + + async def run_revolutions(self, num_revolutions: List[float], use_channels: List[int]): + raise NotImplementedError( + "Revolution based pumping commands are not available for this pump array." + ) + + async def run_continuously(self, speed: List[float], use_channels: List[int]): + for channel, pump_speed in zip(use_channels, speed): + await self._backends[channel].run_continuously(pump_speed) + + async def halt(self): + for backend in self._backends: + await backend.halt() + + +# Deprecated alias +class AgrowPumpArray: + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`AgrowPumpArray` is deprecated. Please use `AgrowPumpArrayBackend` instead." + ) diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py similarity index 51% rename from pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py rename to pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py index b9d6047a0e4..00dd9f43d17 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py @@ -1,26 +1,21 @@ +# mypy: disable-error-code="attr-defined,assignment" import unittest -from unittest.mock import AsyncMock, call +from unittest.mock import AsyncMock, call, patch import pytest pytest.importorskip("pymodbus") -from pymodbus.client import AsyncModbusSerialClient # type: ignore +from pylabrobot.legacy.pumps import PumpArray +from pylabrobot.legacy.pumps.agrowpumps import AgrowPumpArrayBackend -from pylabrobot.pumps import PumpArray -from pylabrobot.pumps.agrowpumps import AgrowPumpArrayBackend +class SimulatedModbusClient: + """Duck-typed modbus client for testing.""" -class SimulatedModbusClient(AsyncModbusSerialClient): - """ - SimulatedModbusClient allows users to simulate Modbus communication. - - Attributes: - connected: A boolean that indicates whether the simulated client is connected. - """ - - def __init__(self, connected: bool = False): - self._connected = connected + def __init__(self): + self._connected = False + self.write_register = AsyncMock() async def connect(self): self._connected = True @@ -29,35 +24,28 @@ async def connect(self): def connected(self): return self._connected - async def read_holding_registers(self, address: int, count: int, **kwargs): # type: ignore - """Simulates reading holding registers from the AgrowPumpArray.""" + 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: - return_register = AsyncMock() - return_register.registers = [16708, 13824, 0, 0, 0, 0, 0][:count] - return return_register - - write_register = AsyncMock() + result = AsyncMock() + result.registers = [16708, 13824, 0, 0, 0, 0, 0][:count] + return result def close(self, reconnect=False): - assert not self.connected, "Modbus connection not established" self._connected = False class TestAgrowPumps(unittest.IsolatedAsyncioTestCase): - """TestAgrowPumps allows users to test AgrowPumps.""" - async def asyncSetUp(self): self.agrow_backend = AgrowPumpArrayBackend(port="simulated", address=1) async def _mock_setup_modbus(): - self.agrow_backend._modbus = SimulatedModbusClient() - - self.agrow_backend._setup_modbus = _mock_setup_modbus # type: ignore[method-assign] + self.agrow_backend.driver._modbus = SimulatedModbusClient() - self.pump_array = PumpArray(backend=self.agrow_backend, calibration=None) - await self.pump_array.setup() + with patch.object(self.agrow_backend.driver, "_setup_modbus", _mock_setup_modbus): + self.pump_array = PumpArray(backend=self.agrow_backend, calibration=None) + await self.pump_array.setup() async def asyncTearDown(self): await self.pump_array.stop() @@ -66,14 +54,14 @@ async def test_setup(self): self.assertEqual(self.agrow_backend.port, "simulated") self.assertEqual(self.agrow_backend.address, 1) self.assertEqual( - self.agrow_backend._pump_index_to_address, + self.agrow_backend.pump_index_to_address, {pump: pump + 100 for pump in range(0, 6)}, ) async def test_run_continuously(self): - self.agrow_backend.modbus.write_register.reset_mock() # type: ignore[attr-defined] + self.agrow_backend.modbus.write_register.reset_mock() await self.pump_array.run_continuously(speed=1, use_channels=[0]) - self.agrow_backend.modbus.write_register.assert_called_once_with(100, 1, unit=1) # type: ignore[attr-defined] + self.agrow_backend.modbus.write_register.assert_called_once_with(100, 1, unit=1) # invalid speed: cannot be bigger than 100 with self.assertRaises(ValueError): @@ -86,6 +74,6 @@ async def test_run_revolutions(self): async def test_halt(self): await self.pump_array.halt() - self.agrow_backend.modbus.write_register.assert_has_calls( # type: ignore[attr-defined] + self.agrow_backend.modbus.write_register.assert_has_calls( [call(100 + i, 0, unit=1) for i in range(6)] ) 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/legacy/pumps/calibration.py b/pylabrobot/legacy/pumps/calibration.py new file mode 100644 index 00000000000..82f3b8969b2 --- /dev/null +++ b/pylabrobot/legacy/pumps/calibration.py @@ -0,0 +1,5 @@ +"""Legacy. Use pylabrobot.capabilities.pumping.calibration instead.""" + +from pylabrobot.capabilities.pumping.calibration import PumpCalibration + +__all__ = ["PumpCalibration"] 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/legacy/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py new file mode 100644 index 00000000000..87779c56756 --- /dev/null +++ b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py @@ -0,0 +1,48 @@ +"""Legacy. Use pylabrobot.cole_parmer instead.""" + +from pylabrobot.cole_parmer.masterflex_backend import MasterflexBackend as _NewBackend +from pylabrobot.cole_parmer.masterflex_backend import MasterflexDriver +from pylabrobot.legacy.pumps.backend import PumpBackend + + +class MasterflexBackend(PumpBackend): + """Legacy. Use pylabrobot.cole_parmer.MasterflexBackend instead.""" + + def __init__(self, com_port: str): + self.driver = MasterflexDriver(com_port=com_port) + self._backend = _NewBackend(self.driver) + + @property + def io(self): + return self.driver.io + + @io.setter + def io(self, value): + self.driver.io = value + + async def setup(self): + await self.driver.setup() + + async def stop(self): + await self.driver.stop() + + def serialize(self): + return {"type": self.__class__.__name__, "com_port": self.driver.com_port} + + async def send_command(self, command: str): + return await self.driver.send_command(command) + + async def run_revolutions(self, num_revolutions: float): + await self._backend.run_revolutions(num_revolutions) + + async def run_continuously(self, speed: float): + await self._backend.run_continuously(speed) + + async def halt(self): + await self._backend.halt() + + +# Deprecated alias +class Masterflex: + def __init__(self, *args, **kwargs): + raise RuntimeError("`Masterflex` is deprecated. Please use `MasterflexBackend` instead.") diff --git a/pylabrobot/legacy/pumps/errors.py b/pylabrobot/legacy/pumps/errors.py new file mode 100644 index 00000000000..64f19783fca --- /dev/null +++ b/pylabrobot/legacy/pumps/errors.py @@ -0,0 +1,2 @@ +class NotCalibratedError(Exception): + """Error raised when calling a method that requires the pump to be calibrated.""" diff --git a/pylabrobot/legacy/pumps/pump.py b/pylabrobot/legacy/pumps/pump.py new file mode 100644 index 00000000000..f70da4882b0 --- /dev/null +++ b/pylabrobot/legacy/pumps/pump.py @@ -0,0 +1,80 @@ +from typing import Optional, Union + +from pylabrobot.capabilities.pumping.backend import PumpBackend as _NewPumpBackend +from pylabrobot.capabilities.pumping.pumping import Pump as _NewPump +from pylabrobot.legacy.machines.machine import Machine + +from .backend import PumpBackend +from .calibration import PumpCalibration + + +class _PumpAdapter(_NewPumpBackend): + """Adapts a legacy PumpBackend to the new PumpBackend (CapabilityBackend).""" + + def __init__(self, legacy: PumpBackend): + self._legacy = legacy + + async def run_revolutions(self, num_revolutions: float): + self._legacy.run_revolutions(num_revolutions=num_revolutions) + + async def run_continuously(self, speed: float): + self._legacy.run_continuously(speed=speed) + + async def halt(self): + self._legacy.halt() + + +class Pump(Machine): + """Frontend for a (peristaltic) pump.""" + + def __init__( + self, + backend: PumpBackend, + calibration: Optional[PumpCalibration] = None, + ): + super().__init__(backend=backend) + self.backend: PumpBackend = backend + if calibration is not None and len(calibration) != 1: + raise ValueError("Calibration may only have a single item for this pump") + self.calibration = calibration + self._pumping = _NewPump(backend=_PumpAdapter(backend), calibration=calibration) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._pumping._on_setup() + + async def stop(self): + await self._pumping._on_stop() + await super().stop() + + def serialize(self) -> dict: + if self.calibration is None: + return super().serialize() + return { + **super().serialize(), + "calibration": self.calibration.serialize(), + } + + @classmethod + def deserialize(cls, data: dict): + data_copy = data.copy() + calibration_data = data_copy.pop("calibration", None) + if calibration_data is not None: + calibration = PumpCalibration.deserialize(calibration_data) # type: ignore[attr-defined] + data_copy["calibration"] = calibration + return super().deserialize(data_copy) + + async def run_revolutions(self, num_revolutions: float): + await self._pumping.run_revolutions(num_revolutions=num_revolutions) + + async def run_continuously(self, speed: float): + await self._pumping.run_continuously(speed=speed) + + async def run_for_duration(self, speed: Union[float, int], duration: Union[float, int]): + await self._pumping.run_for_duration(speed=speed, duration=duration) + + async def pump_volume(self, speed: Union[float, int], volume: Union[float, int]): + await self._pumping.pump_volume(speed=speed, volume=volume) + + async def halt(self): + await self._pumping.halt() 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/legacy/pumps/pumparray.py b/pylabrobot/legacy/pumps/pumparray.py new file mode 100644 index 00000000000..cd55cee76e9 --- /dev/null +++ b/pylabrobot/legacy/pumps/pumparray.py @@ -0,0 +1,183 @@ +import asyncio +from typing import List, Optional, Union + +from pylabrobot.capabilities.pumping.backend import PumpBackend as _NewPumpBackend +from pylabrobot.capabilities.pumping.pumping import Pump +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 _ChannelAdapter(_NewPumpBackend): + """Adapts one channel of a legacy PumpArrayBackend to the new PumpBackend.""" + + def __init__(self, legacy: PumpArrayBackend, channel: int): + self._legacy = legacy + self._channel = channel + + async def run_revolutions(self, num_revolutions: float): + await self._legacy.run_revolutions( + num_revolutions=[num_revolutions], use_channels=[self._channel] + ) + + async def run_continuously(self, speed: float): + await self._legacy.run_continuously(speed=[speed], use_channels=[self._channel]) + + async def halt(self): + await self._legacy.run_continuously(speed=[0.0], use_channels=[self._channel]) + + +class PumpArray(Machine): + """Front-end for a pump array.""" + + def __init__( + self, + backend: PumpArrayBackend, + calibration: Optional[PumpCalibration] = None, + ): + super().__init__(backend=backend) + self.backend: PumpArrayBackend = backend + self.calibration = calibration + self._pumps: List[Pump] = [] + + @property + def num_channels(self) -> int: + return self.backend.num_channels + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + self._pumps = [ + Pump(backend=_ChannelAdapter(self.backend, ch)) for ch in range(self.num_channels) + ] + for p in self._pumps: + await p._on_setup() + + async def stop(self): + for p in reversed(self._pumps): + await p._on_stop() + await super().stop() + + def serialize(self) -> dict: + if self.calibration is None: + return super().serialize() + return { + **super().serialize(), + "calibration": self.calibration.serialize(), + } + + @classmethod + def deserialize(cls, data: dict): + data_copy = data.copy() + calibration_data = data_copy.pop("calibration", None) + if calibration_data is not None: + calibration = PumpCalibration.deserialize(calibration_data) # type: ignore[attr-defined] + data_copy["calibration"] = calibration + return super().deserialize(data_copy) + + # -- helpers ---------------------------------------------------------------- + + def _normalize_channels(self, use_channels: Union[int, List[int]]) -> List[int]: + if isinstance(use_channels, int): + use_channels = [use_channels] + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels in use channels must be unique.") + if any(ch not in range(0, self.num_channels) for ch in use_channels): + raise ValueError( + f"Pump address out of range for this pump array. " + f"Value should be between 0 and {self.num_channels - 1}" + ) + if any(ch < 0 for ch in use_channels): + raise ValueError("Channels in use channels must be positive.") + return use_channels + + @staticmethod + def _normalize_speeds(speed: Union[float, int, List[float], List[int]], n: int) -> List[float]: + if isinstance(speed, (float, int)): + speed = [float(speed)] * n + if any(s < 0 for s in speed): + raise ValueError("Speed must be positive.") + if len(speed) != n: + raise ValueError("Speed and use_channels must be the same length.") + return [float(s) for s in speed] + + # -- public API ------------------------------------------------------------- + + async def run_revolutions( + self, + num_revolutions: Union[float, List[float]], + use_channels: Union[int, List[int]], + ): + channels = self._normalize_channels(use_channels) + if isinstance(num_revolutions, (float, int)): + num_revolutions = [float(num_revolutions)] * len(channels) + if len(num_revolutions) != len(channels): + raise ValueError("num_revolutions and use_channels must be the same length.") + for ch, rev in zip(channels, num_revolutions): + await self._pumps[ch].run_revolutions(num_revolutions=rev) + + async def run_continuously( + self, + speed: Union[float, int, List[float], List[int]], + use_channels: Union[int, List[int]], + ): + channels = self._normalize_channels(use_channels) + speeds = self._normalize_speeds(speed, len(channels)) + for ch, s in zip(channels, speeds): + await self._pumps[ch].run_continuously(speed=s) + + async def run_for_duration( + self, + speed: Union[float, int, List[float], List[int]], + use_channels: Union[int, List[int]], + duration: Union[float, int], + ): + if duration < 0: + raise ValueError("Duration must be positive.") + await self.run_continuously(speed=speed, use_channels=use_channels) + await asyncio.sleep(duration) + await self.run_continuously(speed=0, use_channels=use_channels) + + async def pump_volume( + self, + speed: Union[float, int, List[float], List[int]], + use_channels: Union[int, List[int]], + volume: Union[float, int, List[float], List[int]], + ): + if self.calibration is None: + raise NotCalibratedError( + "Pump is not calibrated. Volume based pumping and related functions unavailable." + ) + channels = self._normalize_channels(use_channels) + speeds = self._normalize_speeds(speed, len(channels)) + if isinstance(volume, (float, int)): + volume = [float(volume)] * len(channels) + if not all(vol >= 0 for vol in volume): + raise ValueError("Volume must be positive.") + if len(volume) != len(channels): + raise ValueError("Speed, use_channels, and volume must be the same length.") + if self.calibration.calibration_mode == "duration": + durations = [ + channel_volume / self.calibration[channel] + for channel, channel_volume in zip(channels, volume) + ] + tasks = [ + asyncio.create_task(self.run_for_duration(speed=s, use_channels=ch, duration=d)) + for s, ch, d in zip(speeds, channels, durations) + ] + elif self.calibration.calibration_mode == "revolutions": + num_rotations = [ + channel_volume / self.calibration[channel] + for channel, channel_volume in zip(channels, volume) + ] + tasks = [ + asyncio.create_task(self.run_revolutions(num_revolutions=r, use_channels=ch)) + for r, ch in zip(num_rotations, channels) + ] + else: + raise ValueError("Calibration mode must be 'duration' or 'revolutions'.") + await asyncio.gather(*tasks) + + async def halt(self): + """Halt the entire pump array.""" + await self.backend.halt() 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/legacy/scales/mettler_toledo_backend.py b/pylabrobot/legacy/scales/mettler_toledo_backend.py new file mode 100644 index 00000000000..74b1aa5226b --- /dev/null +++ b/pylabrobot/legacy/scales/mettler_toledo_backend.py @@ -0,0 +1,124 @@ +"""Legacy. Use pylabrobot.mettler_toledo.MettlerToledoWXS205SDUDriver and +MettlerToledoWXS205SDUScaleBackend instead.""" + +import warnings +from typing import List, Literal, Optional, Union + +from pylabrobot.legacy.scales.scale_backend import ScaleBackend +from pylabrobot.mettler_toledo.mettler_toledo import ( + MettlerToledoError, + MettlerToledoWXS205SDUDriver, + MettlerToledoWXS205SDUScaleBackend, +) + +MettlerToledoError = MettlerToledoError +MettlerToledoResponse = List[str] + + +class MettlerToledoWXS205SDUBackend(ScaleBackend): + """Legacy. Use MettlerToledoWXS205SDUDriver + MettlerToledoWXS205SDUScaleBackend instead.""" + + def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): + self.driver = MettlerToledoWXS205SDUDriver(port=port, vid=vid, pid=pid) + self._scale = MettlerToledoWXS205SDUScaleBackend(self.driver) + + async def setup(self) -> None: + await self.driver.setup() + await self._scale._on_setup() + + async def stop(self) -> None: + await self._scale._on_stop() + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def zero(self, timeout: Union[Literal["stable"], float, int] = "stable"): + return await self._scale.zero(timeout=timeout) + + async def tare(self, timeout: Union[Literal["stable"], float, int] = "stable"): + return await self._scale.tare(timeout=timeout) + + async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: + return await self._scale.read_weight(timeout=timeout) + + async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: + warnings.warn( + "get_weight() is deprecated. Use read_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._scale.read_weight(timeout=timeout) + + async def send_command(self, command: str, timeout: int = 60): + return await self.driver.send_command(command=command, timeout=timeout) + + async def request_serial_number(self) -> str: + return await self._scale.request_serial_number() + + async def request_tare_weight(self) -> float: + return await self._scale.request_tare_weight() + + async def read_stable_weight(self) -> float: + return await self._scale.read_stable_weight() + + async def read_dynamic_weight(self, timeout: float) -> float: + return await self._scale.read_dynamic_weight(timeout=timeout) + + async def read_weight_value_immediately(self) -> float: + return await self._scale.read_weight_value_immediately() + + async def set_display_text(self, text: str): + return await self.driver.set_display_text(text=text) + + async def set_weight_display(self): + return await self.driver.set_weight_display() + + # Deprecated aliases + + async def get_serial_number(self) -> str: + warnings.warn( + "get_serial_number() is deprecated. Use request_serial_number() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._scale.request_serial_number() + + async def get_tare_weight(self) -> float: + warnings.warn( + "get_tare_weight() is deprecated. Use request_tare_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._scale.request_tare_weight() + + async def get_stable_weight(self) -> float: + warnings.warn( + "get_stable_weight() is deprecated. Use read_stable_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._scale.read_stable_weight() + + async def get_dynamic_weight(self, timeout: float) -> float: + warnings.warn( + "get_dynamic_weight() is deprecated. Use read_dynamic_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._scale.read_dynamic_weight(timeout=timeout) + + async def get_weight_value_immediately(self) -> float: + warnings.warn( + "get_weight_value_immediately() is deprecated. Use read_weight_value_immediately() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._scale.read_weight_value_immediately() + + +class MettlerToledoWXS205SDU: + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`MettlerToledoWXS205SDU` is deprecated. Please use `MettlerToledoWXS205SDUBackend` instead." + ) 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 64% rename from pylabrobot/scales/scale_backend.py rename to pylabrobot/legacy/scales/scale_backend.py index 85894aea7c7..0e65e1c4192 100644 --- a/pylabrobot/scales/scale_backend.py +++ b/pylabrobot/legacy/scales/scale_backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.weighing.ScaleBackend instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class ScaleBackend(MachineBackend, metaclass=ABCMeta): @@ -17,15 +19,12 @@ async def read_weight(self) -> float: """Read the weight in grams""" ... - # Deprecated: for backward compatibility async def get_weight(self) -> float: - """Deprecated: Use read_weight() instead. - - Get the weight in grams""" + """Deprecated: Use read_weight() instead.""" import warnings warnings.warn( - "get_weight() is deprecated and will be removed in 2026-03. Use read_weight() instead.", + "get_weight() is deprecated. Use read_weight() instead.", DeprecationWarning, stacklevel=2, ) 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/legacy/sealing/a4s_backend.py b/pylabrobot/legacy/sealing/a4s_backend.py new file mode 100644 index 00000000000..2c11b50fbba --- /dev/null +++ b/pylabrobot/legacy/sealing/a4s_backend.py @@ -0,0 +1,56 @@ +"""Legacy. Use pylabrobot.azenta.A4SDriver / A4SSealerBackend / A4STemperatureBackend instead.""" + +from pylabrobot.azenta.a4s import A4SDriver, A4SSealerBackend, A4STemperatureBackend +from pylabrobot.legacy.sealing.backend import SealerBackend + + +class A4SBackend(SealerBackend): + """Legacy. Use pylabrobot.azenta.A4SDriver / A4SSealerBackend / A4STemperatureBackend instead.""" + + def __init__(self, port: str, timeout: int = 20): + self.driver = A4SDriver(port=port, timeout=timeout) + self._sealer = A4SSealerBackend(self.driver) + self._temperature = A4STemperatureBackend(self.driver) + + async def setup(self): + await self.driver.setup() + await self._sealer._on_setup() + await self._temperature._on_setup() + + async def stop(self): + await self._temperature._on_stop() + await self._sealer._on_stop() + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def seal(self, temperature: int, duration: float): + await self._sealer.seal(temperature=temperature, duration=duration) + + async def open(self): + return await self._sealer.open() + + async def close(self): + return await self._sealer.close() + + async def set_temperature(self, temperature: float): + await self._temperature.set_temperature(temperature=temperature) + + async def get_temperature(self) -> float: + return await self._temperature.request_current_temperature() + + async def set_heater(self, on: bool): + await self.driver.set_heater(on=on) + + async def system_reset(self): + await self.driver.system_reset() + + async def set_time(self, seconds: float): + await self.driver.set_time(seconds=seconds) + + async def get_remaining_time(self) -> int: + return await self.driver.request_remaining_time() + + async def get_status(self): + return await self.driver.request_status() 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/legacy/sealing/sealer.py b/pylabrobot/legacy/sealing/sealer.py new file mode 100644 index 00000000000..51dd1f3db0e --- /dev/null +++ b/pylabrobot/legacy/sealing/sealer.py @@ -0,0 +1,28 @@ +"""Legacy. Use pylabrobot.azenta.A4S instead.""" + +from pylabrobot.legacy.machines import Machine + +from .backend import SealerBackend + + +class Sealer(Machine): + """Legacy. Use pylabrobot.azenta.A4S instead.""" + + def __init__(self, backend: SealerBackend): + super().__init__(backend=backend) + self._backend: SealerBackend = backend + + async def seal(self, temperature: int, duration: float): + return await self._backend.seal(temperature=temperature, duration=duration) + + async def open(self): + return await self._backend.open() + + async def close(self): + return await self._backend.close() + + async def set_temperature(self, temperature: float): + return await self._backend.set_temperature(temperature=temperature) + + async def get_temperature(self) -> float: + return await self._backend.get_temperature() 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 83% rename from pylabrobot/shaking/backend.py rename to pylabrobot/legacy/shaking/backend.py index 23471c6c87b..f16da16a390 100644 --- a/pylabrobot/shaking/backend.py +++ b/pylabrobot/legacy/shaking/backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.shaking.ShakerBackend instead.""" + 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/legacy/shaking/shaker.py b/pylabrobot/legacy/shaking/shaker.py new file mode 100644 index 00000000000..86c9bff2951 --- /dev/null +++ b/pylabrobot/legacy/shaking/shaker.py @@ -0,0 +1,99 @@ +import asyncio +from typing import Optional + +from pylabrobot.capabilities.shaking import Shaker as _NewShaker +from pylabrobot.capabilities.shaking import ShakerBackend as _NewShakerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.resources import Coordinate, ResourceHolder + +from .backend import ShakerBackend + + +class _ShakingAdapter(_NewShakerBackend, HasContinuousShaking): + def __init__(self, legacy: ShakerBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + async def shake(self, speed: float, duration: float, backend_params=None): + await self.start_shaking(speed) + await asyncio.sleep(duration) + await self.stop_shaking() + + async def start_shaking(self, speed: float): + await self._legacy.start_shaking(speed) + + async def stop_shaking(self): + await self._legacy.stop_shaking() + + @property + def supports_locking(self) -> bool: + return self._legacy.supports_locking + + async def lock_plate(self): + await self._legacy.lock_plate() + + async def unlock_plate(self): + await self._legacy.unlock_plate() + + +class Shaker(ResourceHolder, Machine): + """Legacy. Use a vendor-specific machine with Shaker instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: ShakerBackend, + child_location: Coordinate, + category: str = "shaker", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + child_location=child_location, + ) + Machine.__init__(self, backend=backend) + self.backend: ShakerBackend = backend + self._shaking_cap = _NewShaker(backend=_ShakingAdapter(backend)) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._shaking_cap._on_setup() + + async def shake(self, speed: float, duration: Optional[float] = None, **backend_kwargs): + if duration is not None: + return await self._shaking_cap.shake(speed=speed, duration=duration) + return await self._shaking_cap.start_shaking(speed=speed) + + async def stop_shaking(self, **backend_kwargs): + await self._shaking_cap.stop_shaking() + + async def lock_plate(self, **backend_kwargs): + await self._shaking_cap.lock_plate() + + async def unlock_plate(self, **backend_kwargs): + await self._shaking_cap.unlock_plate() + + async def stop(self): + await self._shaking_cap._on_stop() + await super().stop() + + def serialize(self) -> dict: + return { + **Machine.serialize(self), + **ResourceHolder.serialize(self), + } 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 91% rename from pylabrobot/storage/chatterbox.py rename to pylabrobot/legacy/storage/chatterbox.py index 89115098f00..2e7d3f40ae0 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): @@ -27,6 +27,7 @@ async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs) async def set_temperature(self, temperature: float): print(f"Setting temperature to {temperature}") + self._dummy_temperature = temperature async def get_temperature(self) -> float: print("Getting temperature") diff --git a/pylabrobot/legacy/storage/cytomat/__init__.py b/pylabrobot/legacy/storage/cytomat/__init__.py new file mode 100644 index 00000000000..5c84ac1433b --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/__init__.py @@ -0,0 +1,2 @@ +from .constants import CytomatType +from .cytomat import CytomatBackend, CytomatChatterbox diff --git a/pylabrobot/legacy/storage/cytomat/constants.py b/pylabrobot/legacy/storage/cytomat/constants.py new file mode 100644 index 00000000000..276b6986fd8 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/constants.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.constants instead.""" + +from pylabrobot.thermo_fisher.cytomat.constants import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/cytomat.py b/pylabrobot/legacy/storage/cytomat/cytomat.py new file mode 100644 index 00000000000..ffd6fe6623a --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/cytomat.py @@ -0,0 +1,175 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.CytomatBackend instead.""" + +from typing import List, Optional, Union + +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.legacy.storage.cytomat.constants import CytomatType +from pylabrobot.resources import Plate, PlateCarrier, PlateHolder +from pylabrobot.thermo_fisher.cytomat import backend as new_cytomat + + +class CytomatBackend(IncubatorBackend): + """Legacy. Use pylabrobot.thermo_fisher.cytomat.CytomatBackend instead.""" + + def __init__(self, model: Union[CytomatType, str], port: str): + super().__init__() + self._new = new_cytomat.CytomatBackend(model=model, port=port) + + @property + def model(self): + return self._new.model + + @property + def io(self): + return self._new.io + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + await self._new.set_racks(racks) + + async def open_door(self): + return await self._new.open_door() + + async def close_door(self): + return await self._new.close_door() + + async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): + await self._new.fetch_plate_to_loading_tray(plate) + + async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): + await self._new.store_plate(plate, site) + + async def set_temperature(self, *args, **kwargs): + return await self._new.set_temperature(*args, **kwargs) + + async def get_temperature(self) -> float: + return await self._new.request_current_temperature() + + async def start_shaking(self, frequency: float, shakers: Optional[List[int]] = None): + return await self._new.start_shaking(speed=frequency, shakers=shakers) + + async def stop_shaking(self): + return await self._new.stop_shaking() + + # Device-specific methods delegated to new backend + + def _assemble_command(self, command_type: str, command: str, params: str): + return self._new._assemble_command(command_type, command, params) + + async def send_command(self, command_type: str, command: str, params: str) -> str: + return await self._new.send_command(command_type, command, params) + + async def send_action(self, command_type, command, params, timeout=60): + return await self._new.send_action(command_type, command, params, timeout=timeout) + + async def get_overview_register(self): + return await self._new.request_overview_register() + + async def get_warning_register(self): + return await self._new.request_warning_register() + + async def get_error_register(self): + return await self._new.request_error_register() + + async def reset_error_register(self): + return await self._new.reset_error_register() + + async def initialize(self): + return await self._new.initialize() + + async def shovel_in(self): + return await self._new.shovel_in() + + async def shovel_out(self): + return await self._new.shovel_out() + + async def get_action_register(self): + return await self._new.request_action_register() + + async def get_swap_register(self): + return await self._new.request_swap_register() + + async def get_sensor_register(self): + return await self._new.request_sensor_register() + + async def action_transfer_to_storage(self, site): + return await self._new.action_transfer_to_storage(site) + + async def action_storage_to_transfer(self, site): + return await self._new.action_storage_to_transfer(site) + + async def action_storage_to_wait(self, site): + return await self._new.action_storage_to_wait(site) + + async def action_wait_to_storage(self, site): + return await self._new.action_wait_to_storage(site) + + async def action_wait_to_transfer(self): + return await self._new.action_wait_to_transfer() + + async def action_transfer_to_wait(self): + return await self._new.action_transfer_to_wait() + + async def action_wait_to_exposed(self): + return await self._new.action_wait_to_exposed() + + async def action_exposed_to_wait(self): + return await self._new.action_exposed_to_wait() + + async def action_exposed_to_storage(self, site): + return await self._new.action_exposed_to_storage(site) + + async def action_storage_to_exposed(self, site): + return await self._new.action_storage_to_exposed(site) + + async def action_read_barcode(self, site_number_a, site_number_b): + return await self._new.action_read_barcode(site_number_a, site_number_b) + + async def wait_for_transfer_station(self, occupied=False): + return await self._new.wait_for_transfer_station(occupied=occupied) + + async def wait_for_task_completion(self, timeout=60): + return await self._new.wait_for_task_completion(timeout=timeout) + + async def init_shakers(self): + return await self._new.init_shakers() + + async def set_shaking_frequency(self, frequency, shakers=None): + return await self._new.set_shaking_frequency(frequency, shakers) + + async def get_incubation_query(self, query): + return await self._new.request_incubation_query(query) + + async def get_co2(self): + return await self._new.request_co2() + + async def get_humidity(self): + return await self._new.request_humidity() + + async def get_o2(self): + return await self._new.request_o2() + + def serialize(self) -> dict: + return self._new.serialize() + + +class CytomatChatterbox(CytomatBackend): + """Legacy. Use pylabrobot.thermo_fisher.cytomat.CytomatChatterbox instead.""" + + def __init__(self, model: Union[CytomatType, str], port: str): + # Skip CytomatBackend.__init__ and use the new chatterbox directly + IncubatorBackend.__init__(self) + from pylabrobot.thermo_fisher.cytomat.chatterbox import CytomatChatterbox as NewChatterbox + + self._new = NewChatterbox(model=model, port=port) + + +class Cytomat: + def __init__(self, *args, **kwargs): + raise RuntimeError("`Cytomat` is deprecated. Please use `CytomatBackend` instead. ") diff --git a/pylabrobot/legacy/storage/cytomat/errors.py b/pylabrobot/legacy/storage/cytomat/errors.py new file mode 100644 index 00000000000..76fc9c64240 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/errors.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.errors instead.""" + +from pylabrobot.thermo_fisher.cytomat.errors import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py new file mode 100644 index 00000000000..66bb8263c3a --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py @@ -0,0 +1,67 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.HeraeusCytomatBackend instead.""" + +from typing import List + +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources.carrier import PlateCarrier +from pylabrobot.thermo_fisher.cytomat import heraeus_backend as new_heraeus + + +class HeraeusCytomatBackend(IncubatorBackend): + """Legacy. Use pylabrobot.thermo_fisher.cytomat.HeraeusCytomatBackend instead.""" + + def __init__(self, port: str): + super().__init__() + self._new = new_heraeus.HeraeusCytomatBackend(port=port) + + @property + def io(self): + return self._new.io + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + await self._new.set_racks(racks) + + async def open_door(self): + await self._new.open_door() + + async def close_door(self): + await self._new.close_door() + + async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): + await self._new.fetch_plate_to_loading_tray(plate) + + async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): + await self._new.store_plate(plate, site) + + async def set_temperature(self, temperature: float): + return await self._new.set_temperature(temperature) + + async def get_temperature(self) -> float: + return await self._new.request_current_temperature() + + async def start_shaking(self, frequency: float = 1.0): + await self._new.start_shaking(speed=frequency) + + async def stop_shaking(self): + await self._new.stop_shaking() + + async def wait_for_transfer_station(self, occupied: bool = False): + await self._new.wait_for_transfer_station(occupied=occupied) + + async def initialize(self): + await self._new.initialize() + + def serialize(self) -> dict: + return self._new.serialize() + + @classmethod + def deserialize(cls, data: dict): + return cls(port=data["port"]) diff --git a/pylabrobot/legacy/storage/cytomat/racks.py b/pylabrobot/legacy/storage/cytomat/racks.py new file mode 100644 index 00000000000..db5fa8f1160 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/racks.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.racks instead.""" + +from pylabrobot.thermo_fisher.cytomat.racks import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/schemas.py b/pylabrobot/legacy/storage/cytomat/schemas.py new file mode 100644 index 00000000000..a911589db0e --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/schemas.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.schemas instead.""" + +from pylabrobot.thermo_fisher.cytomat.schemas import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/utils.py b/pylabrobot/legacy/storage/cytomat/utils.py new file mode 100644 index 00000000000..94661c4c05e --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/utils.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.utils instead.""" + +from pylabrobot.thermo_fisher.cytomat.utils import * # noqa: F401,F403 diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/legacy/storage/incubator.py similarity index 95% rename from pylabrobot/storage/incubator.py rename to pylabrobot/legacy/storage/incubator.py index 4a4d6d5fe64..fd1c1acb7a4 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, @@ -196,16 +196,16 @@ def serialize(self): @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): - backend = IncubatorBackend.deserialize(data.pop("backend")) + rotation_data = data.get("rotation") return cls( - backend=backend, + backend=IncubatorBackend.deserialize(data["backend"]), name=data["name"], size_x=data["size_x"], size_y=data["size_y"], 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=deserialize(rotation_data) if rotation_data else None, + 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 100% rename from pylabrobot/storage/inheco/__init__.py rename to pylabrobot/legacy/storage/inheco/__init__.py 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..2627b7ae797 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.backend 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/legacy/storage/inheco/scila/inheco_sila_interface.py b/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py new file mode 100644 index 00000000000..29d7d7c05a7 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py @@ -0,0 +1,6 @@ +"""Legacy. Use pylabrobot.inheco.scila.InhecoSiLAInterface instead.""" + +from pylabrobot.inheco.scila.inheco_sila_interface import ( # noqa: F401 + InhecoSiLAInterface, + SiLAError, +) diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py new file mode 100644 index 00000000000..3e0049222b8 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py @@ -0,0 +1,77 @@ +"""Legacy. Use pylabrobot.inheco.scila.SCILADriver and SCILATemperatureBackend instead.""" + +from typing import Any, Dict, Literal, Optional + +from pylabrobot.inheco.scila.scila_backend import SCILADriver, SCILATemperatureBackend +from pylabrobot.legacy.machines.backend import MachineBackend + +DrawerStatus = Literal["Opened", "Closed"] + + +class SCILABackend(MachineBackend): + """Legacy. Use pylabrobot.inheco.scila.SCILADriver and SCILATemperatureBackend instead.""" + + def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: + self.driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) + self._temp = SCILATemperatureBackend(driver=self.driver) + + @property + def _sila_interface(self): + return self.driver._sila_interface + + async def setup(self) -> None: + await self.driver.setup() + await self._temp._on_setup() + + async def stop(self) -> None: + await self._temp._on_stop() + await self.driver.stop() + + async def request_status(self) -> str: + return await self.driver.request_status() + + async def request_liquid_level(self) -> str: + return await self.driver.request_liquid_level() + + async def request_temperature_information(self) -> dict[str, Any]: + return await self._temp.request_temperature_information() + + async def measure_temperature(self) -> float: + return await self._temp.request_current_temperature() + + async def request_target_temperature(self) -> float: + return await self._temp.request_target_temperature() + + async def is_temperature_control_enabled(self) -> bool: + return await self._temp.is_temperature_control_enabled() + + async def open(self, drawer_id: int) -> None: + await self.driver.open(drawer_id=drawer_id) + + async def close(self, drawer_id: int) -> None: + await self.driver.close(drawer_id=drawer_id) + + async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: + return await self.driver.request_drawer_statuses() + + async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: + return await self.driver.request_drawer_status(drawer_id=drawer_id) + + async def request_co2_flow_status(self) -> str: + return await self.driver.request_co2_flow_status() + + async def request_valve_status(self) -> dict[str, str]: + return await self.driver.request_valve_status() + + async def start_temperature_control(self, temperature: float) -> None: + await self._temp.set_temperature(temperature=temperature) + + async def stop_temperature_control(self) -> None: + await self._temp.deactivate() + + def serialize(self) -> dict[str, Any]: + return self.driver.serialize() + + @classmethod + def deserialize(cls, data: dict[str, Any]) -> "SCILABackend": + return cls(scila_ip=data["scila_ip"], client_ip=data.get("client_ip")) diff --git a/pylabrobot/storage/inheco/scila/scila_backend_tests.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py similarity index 94% rename from pylabrobot/storage/inheco/scila/scila_backend_tests.py rename to pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py index 03cad4dd5f7..961b03d6d2a 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py @@ -2,13 +2,14 @@ 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.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.legacy.machines.backend import MachineBackend +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.inheco.scila.scila_backend.InhecoSiLAInterface") self.MockInhecoSiLAInterface = self.patcher.start() self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) self.mock_sila_interface.bound_port = 80 @@ -23,7 +24,7 @@ async def test_setup(self): await self.backend.setup() self.mock_sila_interface.setup.assert_called_once() self.mock_sila_interface.send_command.assert_any_call( - command="Reset", + "Reset", deviceId="MyController", eventReceiverURI="http://127.0.0.1:80/", simulationMode=False, @@ -220,15 +221,15 @@ def test_serialize_no_client_ip(self): self.assertIsNone(data["client_ip"]) def test_deserialize(self): - data = {"scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} - SCILABackend.deserialize(data) + data = {"type": "SCILABackend", "scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} + MachineBackend.deserialize(data) self.MockInhecoSiLAInterface.assert_called_with( client_ip="192.168.1.10", machine_ip="169.254.1.117" ) def test_deserialize_no_client_ip(self): - data = {"scila_ip": "169.254.1.117"} - SCILABackend.deserialize(data) + data = {"type": "SCILABackend", "scila_ip": "169.254.1.117"} + MachineBackend.deserialize(data) self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117") diff --git a/pylabrobot/legacy/storage/inheco/scila/soap.py b/pylabrobot/legacy/storage/inheco/scila/soap.py new file mode 100644 index 00000000000..85befb6c908 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/soap.py @@ -0,0 +1,4 @@ +"""Legacy. Use pylabrobot.inheco.scila.soap instead.""" + +from pylabrobot.inheco.scila.soap import * # noqa: F401, F403 +from pylabrobot.inheco.scila.soap import XSI # noqa: F401 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/legacy/storage/liconic/constants.py b/pylabrobot/legacy/storage/liconic/constants.py new file mode 100644 index 00000000000..83dfc20f554 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/constants.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.liconic.constants instead.""" + +from pylabrobot.liconic.constants import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/liconic/errors.py b/pylabrobot/legacy/storage/liconic/errors.py new file mode 100644 index 00000000000..d125b31b0e6 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/errors.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.liconic.errors instead.""" + +from pylabrobot.liconic.errors import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/liconic/liconic_backend.py b/pylabrobot/legacy/storage/liconic/liconic_backend.py new file mode 100644 index 00000000000..3473ae2ead6 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/liconic_backend.py @@ -0,0 +1,268 @@ +"""Legacy. Use pylabrobot.liconic.LiconicBackend instead.""" + +from typing import List, Union + +from pylabrobot.capabilities.barcode_scanning.backend import ( + BarcodeScannerBackend as _NewBarcodeScannerBackend, +) +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.liconic import backend as new_liconic +from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.carrier import PlateCarrier + +# Re-export for legacy imports +LICONIC_SITE_HEIGHT_TO_STEPS = new_liconic.LICONIC_SITE_HEIGHT_TO_STEPS + + +class _BarcodeScannerAdapter(_NewBarcodeScannerBackend): + """Adapts a legacy BarcodeScanner Machine to the new BarcodeScannerBackend interface.""" + + def __init__(self, legacy_scanner): + self._legacy = legacy_scanner + + async def setup(self): + pass + + async def stop(self): + pass + + async def scan_barcode(self) -> Barcode: + result: Barcode = await self._legacy.scan() + return result + + +class ExperimentalLiconicBackend(IncubatorBackend): + """Legacy. Use pylabrobot.liconic.LiconicBackend instead.""" + + # Internal attributes that should be forwarded to self._new for test compatibility + _FORWARDED_ATTRS = { + "_send_command", + "_wait_ready", + "_wait_plate_ready", + "_carrier_to_steps_pos", + "_site_to_m_n", + "_racks", + "io", + } + + def __init__( + self, + model: Union["new_liconic.LiconicType", str], + port: str, + barcode_scanner=None, + ): + super().__init__() + self._new = new_liconic.LiconicBackend(model=model, port=port) + self.barcode_scanner = barcode_scanner + + @property + def _barcode_adapter(self) -> _BarcodeScannerAdapter: + """Wrap the legacy BarcodeScanner Machine for the new backend interface.""" + assert self.barcode_scanner is not None, "Barcode scanner not configured" + return _BarcodeScannerAdapter(self.barcode_scanner) + + def __getattr__(self, name): + if name in self._FORWARDED_ATTRS: + return getattr(self._new, name) + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + if name != "_new" and hasattr(self, "_new") and name in self._FORWARDED_ATTRS: + setattr(self._new, name, value) + else: + super().__setattr__(name, value) + + @property + def model(self): + return self._new.model + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + await self._new.set_racks(racks) + + async def initialize(self): + await self._new.initialize() + + async def open_door(self): + await self._new.open_door() + + async def close_door(self): + await self._new.close_door() + + async def fetch_plate_to_loading_tray( + self, plate: Plate, read_barcode: bool = False, **backend_kwargs + ): + if not read_barcode: + await self._new.fetch_plate_to_loading_tray(plate) + return + + # Can't use the bundled fetch method because the original interleaves barcode reading + # between DM writes and ST 1905. Replicate the original command sequence. + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + site = plate.parent + assert isinstance(site, PlateHolder) + m, n = self._new._site_to_m_n(site) + step_size, pos_num = self._new._carrier_to_steps_pos(site) + + await self._new._send_command(f"WR DM0 {m}") + await self._new._send_command(f"WR DM23 {step_size}") + await self._new._send_command(f"WR DM25 {pos_num}") + await self._new._send_command(f"WR DM5 {n}") + + plate.barcode = await self._new.read_barcode_inline(m, n, self._barcode_adapter) + + await self._new._send_command("ST 1905") + await self._new._wait_ready() + await self._new._send_command("ST 1903") + + async def take_in_plate( + self, plate: Plate, site: PlateHolder, read_barcode: bool = False, **backend_kwargs + ): + if not read_barcode: + await self._new.store_plate(plate, site) + return + + # Can't use the bundled store method because the original reads barcode + # AFTER ST 1904/wait but BEFORE ST 1903 (terminate access). + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + m, n = self._new._site_to_m_n(site) + step_size, pos_num = self._new._carrier_to_steps_pos(site) + + await self._new._send_command(f"WR DM0 {m}") + await self._new._send_command(f"WR DM23 {step_size}") + await self._new._send_command(f"WR DM25 {pos_num}") + await self._new._send_command(f"WR DM5 {n}") + await self._new._send_command("ST 1904") + await self._new._wait_ready() + + plate.barcode = await self._new.read_barcode_inline(m, n, self._barcode_adapter) + + await self._new._send_command("ST 1903") + + async def move_position_to_position( + self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False + ): + if not read_barcode: + await self._new.move_position_to_position(plate, dest_site) + return + + # Can't use the bundled move method because the original reads barcode + # AFTER DM writes but BEFORE ST 1908 (pick plate). + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + orig_site = plate.parent + assert isinstance(orig_site, PlateHolder) + assert isinstance(dest_site, PlateHolder) + + if dest_site.resource is not None: + raise RuntimeError(f"Position {dest_site} already has a plate assigned!") + + orig_m, orig_n = self._new._site_to_m_n(orig_site) + dest_m, dest_n = self._new._site_to_m_n(dest_site) + orig_step_size, orig_pos_num = self._new._carrier_to_steps_pos(orig_site) + dest_step_size, dest_pos_num = self._new._carrier_to_steps_pos(dest_site) + + await self._new._send_command(f"WR DM0 {orig_m}") + await self._new._send_command(f"WR DM23 {orig_step_size}") + await self._new._send_command(f"WR DM25 {orig_pos_num}") + await self._new._send_command(f"WR DM5 {orig_n}") + + plate.barcode = await self._new.read_barcode_inline(orig_m, orig_n, self._barcode_adapter) + + await self._new._send_command("ST 1908") + await self._new._wait_ready() + + if orig_m != dest_m: + await self._new._send_command(f"WR DM0 {dest_m}") + await self._new._send_command(f"WR DM23 {dest_step_size}") + await self._new._send_command(f"WR DM25 {dest_pos_num}") + await self._new._send_command(f"WR DM5 {dest_n}") + await self._new._send_command("ST 1909") + await self._new._wait_ready() + await self._new._send_command("ST 1903") + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_temperature(self) -> float: + return await self._new.request_current_temperature() + + async def start_shaking(self, frequency): + await self._new.start_shaking(speed=frequency) + + async def stop_shaking(self): + await self._new.stop_shaking() + + async def get_shaker_speed(self) -> float: + return await self._new.request_shaker_speed() + + async def shaker_status(self) -> int: + raise NotImplementedError("shaker_status command not yet implemented") + + async def get_target_temperature(self) -> float: + return await self._new.request_target_temperature() + + async def set_humidity(self, humidity: float): + await self._new.set_humidity(humidity) + + async def get_humidity(self) -> float: + return await self._new.request_current_humidity() + + async def get_target_humidity(self) -> float: + return await self._new.request_target_humidity() + + async def set_co2_level(self, co2_level: float): + await self._new.set_co2_level(co2_level) + + async def get_co2_level(self) -> float: + return await self._new.request_co2_level() + + async def get_target_co2_level(self) -> float: + return await self._new.request_target_co2_level() + + async def set_n2_level(self, n2_level: float): + await self._new.set_n2_level(n2_level) + + async def get_n2_level(self) -> float: + return await self._new.request_n2_level() + + async def get_target_n2_level(self) -> float: + return await self._new.request_target_n2_level() + + async def turn_swap_station(self, home: bool): + await self._new.turn_swap_station(home) + + async def check_shovel_sensor(self) -> bool: + return await self._new.check_shovel_sensor() + + async def check_transfer_sensor(self) -> bool: + return await self._new.check_transfer_sensor() + + async def check_second_transfer_sensor(self) -> bool: + return await self._new.check_second_transfer_sensor() + + async def read_barcode_inline(self, cassette: int, plt_position: int): + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + return await self._new.read_barcode_inline(cassette, plt_position, self._barcode_adapter) + + async def scan_barcode(self, site: PlateHolder): + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + return await self._new.scan_barcode(site, self._barcode_adapter) + + def serialize(self) -> dict: + return self._new.serialize() + + @classmethod + def deserialize(cls, data: dict): + return cls(port=data["port"], model=data["model"]) diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/legacy/storage/liconic/liconic_backend_tests.py similarity index 97% rename from pylabrobot/storage/liconic/liconic_backend_tests.py rename to pylabrobot/legacy/storage/liconic/liconic_backend_tests.py index b3ed09dccfa..2196428532c 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): @@ -154,7 +154,7 @@ class TestValueConversions(unittest.IsolatedAsyncioTestCase): """Test the PLC register value conversions without actual serial IO.""" def setUp(self): - self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null") + self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_DC2, port="/dev/null") self.backend._send_command = AsyncMock(return_value="OK") self.backend._wait_ready = AsyncMock() @@ -305,7 +305,7 @@ async def test_check_second_transfer_sensor_false(self): class TestClimateGetters(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null") + self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_DC2, port="/dev/null") self.backend._wait_ready = AsyncMock() async def test_get_target_temperature(self): @@ -398,7 +398,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/legacy/storage/liconic/racks.py b/pylabrobot/legacy/storage/liconic/racks.py new file mode 100644 index 00000000000..50b897d34b6 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/racks.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.liconic.racks instead.""" + +from pylabrobot.liconic.racks import * # noqa: F401,F403 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 83% rename from pylabrobot/temperature_controlling/backend.py rename to pylabrobot/legacy/temperature_controlling/backend.py index 3fdeff95584..34df99a2bae 100644 --- a/pylabrobot/temperature_controlling/backend.py +++ b/pylabrobot/legacy/temperature_controlling/backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.temperature_controlling instead.""" + 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/legacy/temperature_controlling/inheco/control_box.py b/pylabrobot/legacy/temperature_controlling/inheco/control_box.py new file mode 100644 index 00000000000..306e97d33c2 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/inheco/control_box.py @@ -0,0 +1,5 @@ +"""Legacy re-export. Use pylabrobot.inheco.InhecoTECControlBox instead.""" + +from pylabrobot.inheco.control_box import InhecoTECControlBox + +__all__ = ["InhecoTECControlBox"] 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/legacy/temperature_controlling/inheco/cpac_backend.py b/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py new file mode 100644 index 00000000000..b5932d3b9f8 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py @@ -0,0 +1,11 @@ +from pylabrobot.inheco import cpac +from pylabrobot.legacy.temperature_controlling.inheco.temperature_controller import ( + InhecoTemperatureControllerBackend, +) + + +class InhecoCPACBackend(InhecoTemperatureControllerBackend): + """Legacy. Use pylabrobot.inheco.cpac.InhecoCPACBackend instead.""" + + def __init__(self, index: int, control_box): + self._new = cpac.InhecoCPACBackend(index=index, control_box=control_box) diff --git a/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py new file mode 100644 index 00000000000..0a69311d426 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py @@ -0,0 +1,51 @@ +from pylabrobot.inheco import cpac +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend + + +class InhecoTemperatureControllerBackend(TemperatureControllerBackend): + """Legacy. Use pylabrobot.inheco.cpac.InhecoTemperatureControllerBackend instead.""" + + def __init__(self, index: int, control_box): + self._new = cpac.InhecoTemperatureControllerBackend(index=index, control_box=control_box) + + @property + def index(self) -> int: + return self._new.index + + @property + def interface(self): + return self._new.interface + + @property + def supports_active_cooling(self) -> bool: + return self._new.supports_active_cooling + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._new.request_current_temperature() + + async def deactivate(self): + await self._new.deactivate() + + async def set_target_temperature(self, temperature: float): + await self._new.set_target_temperature(temperature) + + async def start_temperature_control(self): + return await self._new.start_temperature_control() + + async def stop_temperature_control(self): + return await self._new.stop_temperature_control() + + async def get_device_info(self, info_type: int): + return await self._new.request_device_info(info_type) 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/legacy/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py new file mode 100644 index 00000000000..92dcfffa8be --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -0,0 +1,44 @@ +"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleTemperatureBackend instead.""" + +from pylabrobot.legacy.temperature_controlling.backend import ( + TemperatureControllerBackend, +) +from pylabrobot.opentrons.temperature_module import ( + OpentronsTemperatureModuleDriver, +) +from pylabrobot.opentrons.temperature_module import ( + OpentronsTemperatureModuleTemperatureBackend as _NewBackend, +) + + +class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): + """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleTemperatureBackend instead.""" + + @property + def supports_active_cooling(self) -> bool: + return self._backend.supports_active_cooling + + def __init__(self, opentrons_id: str): + self.driver = OpentronsTemperatureModuleDriver(opentrons_id=opentrons_id) + self._backend = _NewBackend(driver=self.driver) + self.opentrons_id = opentrons_id + + async def setup(self): + await self.driver.setup() + await self._backend._on_setup() + + async def stop(self): + await self._backend._on_stop() + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def set_temperature(self, temperature: float): + await self._backend.set_temperature(temperature) + + async def deactivate(self): + await self._backend.deactivate() + + async def get_current_temperature(self) -> float: + return await self._backend.request_current_temperature() diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py new file mode 100644 index 00000000000..cbabb8090d4 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -0,0 +1,44 @@ +"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBTemperatureBackend instead.""" + +from pylabrobot.legacy.temperature_controlling.backend import ( + TemperatureControllerBackend, +) +from pylabrobot.opentrons.temperature_module import ( + OpentronsTemperatureModuleUSBDriver, +) +from pylabrobot.opentrons.temperature_module import ( + OpentronsTemperatureModuleUSBTemperatureBackend as _NewBackend, +) + + +class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): + """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBTemperatureBackend instead.""" + + @property + def supports_active_cooling(self) -> bool: + return self._backend.supports_active_cooling + + def __init__(self, port: str): + self.driver = OpentronsTemperatureModuleUSBDriver(port=port) + self._backend = _NewBackend(driver=self.driver) + self.port = port + + async def setup(self): + await self.driver.setup() + await self._backend._on_setup() + + async def stop(self): + await self._backend._on_stop() + await self.driver.stop() + + def serialize(self) -> dict: + return self.driver.serialize() + + async def set_temperature(self, temperature: float): + await self._backend.set_temperature(temperature) + + async def deactivate(self): + await self._backend.deactivate() + + async def get_current_temperature(self) -> float: + return await self._backend.request_current_temperature() diff --git a/pylabrobot/legacy/temperature_controlling/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/temperature_controller.py new file mode 100644 index 00000000000..63f69854425 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller.py @@ -0,0 +1,97 @@ +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import TemperatureController as _NewTC +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControllerBackend as _NewTCBackend, +) +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.resources import Coordinate, ResourceHolder + +from .backend import TemperatureControllerBackend + + +class _TemperatureControlAdapter(_NewTCBackend): + def __init__(self, legacy: TemperatureControllerBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + @property + def supports_active_cooling(self) -> bool: + return self._legacy.supports_active_cooling + + async def set_temperature(self, temperature: float): + await self._legacy.set_temperature(temperature) + + async def request_current_temperature(self) -> float: + return await self._legacy.get_current_temperature() + + async def deactivate(self): + await self._legacy.deactivate() + + +class TemperatureController(ResourceHolder, Machine): + """Legacy. Use pylabrobot.inheco.InhecoCPAC (or vendor-specific class) instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: TemperatureControllerBackend, + 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, + ) + Machine.__init__(self, backend=backend) + self.backend: TemperatureControllerBackend = backend + self._tc_cap = _NewTC(backend=_TemperatureControlAdapter(backend)) + + @property + def target_temperature(self) -> Optional[float]: + return self._tc_cap.target_temperature + + @target_temperature.setter + def target_temperature(self, value: Optional[float]): + self._tc_cap.target_temperature = value + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._tc_cap._on_setup() + + async def set_temperature(self, temperature: float, passive: bool = False): + return await self._tc_cap.set_temperature(temperature, passive=passive) + + async def get_temperature(self) -> float: + return await self._tc_cap.request_temperature() + + async def wait_for_temperature(self, timeout: float = 300.0, tolerance: float = 0.5) -> None: + return await self._tc_cap.wait_for_temperature(timeout=timeout, tolerance=tolerance) + + async def deactivate(self): + return await self._tc_cap.deactivate() + + async def stop(self): + await self._tc_cap._on_stop() + await super().stop() + + def serialize(self) -> dict: + return { + **Machine.serialize(self), + **ResourceHolder.serialize(self), + } diff --git a/pylabrobot/temperature_controlling/temperature_controller_tests.py b/pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py similarity index 75% rename from pylabrobot/temperature_controlling/temperature_controller_tests.py rename to pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py index 5e2a5c8cb86..56cb71f3090 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): @@ -36,8 +36,12 @@ async def test_cannot_cool_without_support(self): child_location=Coordinate.zero(), ) - with self.assertRaises(ValueError): - await tc.set_temperature(10) + await tc.setup() + try: + with self.assertRaises(ValueError): + await tc.set_temperature(10) + finally: + await tc.stop() async def test_passive_cooling_without_support(self): backend = TemperatureControllerChatterboxBackend(dummy_temperature=20.0) @@ -50,9 +54,13 @@ async def test_passive_cooling_without_support(self): child_location=Coordinate.zero(), ) - await tc.set_temperature(10, passive=True) - # Temperature should remain unchanged on the backend. - self.assertEqual(await backend.get_current_temperature(), 20.0) + await tc.setup() + try: + await tc.set_temperature(10, passive=True) + # Temperature should remain unchanged on the backend. + self.assertEqual(await backend.get_current_temperature(), 20.0) + finally: + await tc.stop() class _FakeBackend(TemperatureControllerBackend): @@ -94,5 +102,9 @@ async def test_passive_cooling_with_support(self): child_location=Coordinate.zero(), ) - await tc.set_temperature(20, passive=True) - self.assertFalse(backend.set_called) + await tc.setup() + try: + await tc.set_temperature(20, passive=True) + self.assertFalse(backend.set_called) + finally: + await tc.stop() 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 97% rename from pylabrobot/thermocycling/opentrons_backend.py rename to pylabrobot/legacy/thermocycling/opentrons_backend.py index f2b444c786d..9cd569bd2c0 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 b883b99b1f4..59f7600a82a 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", diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py similarity index 83% rename from pylabrobot/thermocycling/opentrons_backend_usb.py rename to pylabrobot/legacy/thermocycling/opentrons_backend_usb.py index 41daf9a002c..7b51512f593 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, @@ -97,7 +97,7 @@ def __init__(self): if not USE_OPENTRONS_DRIVER: raise RuntimeError("Opentrons thermocycler driver not available") from _import_error - self._driver: Optional[AbstractThermocyclerDriver] = None + self.driver: Optional[AbstractThermocyclerDriver] = None self._current_protocol: Optional[Protocol] = None self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -120,9 +120,9 @@ async def _execute_cycle_step( volume: Optional[float] = None, ) -> None: """Execute a single thermocycler step (uses shared utility).""" - assert self._driver is not None + assert self.driver is not None await execute_cycle_step( - driver=self._driver, + driver=self.driver, temperature=temperature, hold_time_seconds=hold_time_seconds, ramp_rate=ramp_rate, @@ -136,7 +136,7 @@ async def _execute_cycles( volume: Optional[float], ) -> None: """Execute cycles of temperature steps directly from protocol (with cycle tracking).""" - assert self._driver is not None + assert self.driver is not None self._total_cycle_count = repetitions self._total_step_count = 0 for stage in protocol.stages: @@ -159,7 +159,7 @@ async def _execute_cycles( ramp_rate = step.rate if step.rate is not None else None self._current_step_index += 1 await execute_cycle_step( - driver=self._driver, + driver=self.driver, temperature=temperature, hold_time_seconds=hold_time, ramp_rate=ramp_rate, @@ -208,32 +208,32 @@ async def setup(self, port: Optional[str] = None): else: port = opentrons_ports[0].device - self._driver = await ThermocyclerDriverFactory.create(port, self._loop) + self.driver = await ThermocyclerDriverFactory.create(port, self._loop) async def stop(self): - if self._driver is not None: + if self.driver is not None: await self.deactivate_block() await self.deactivate_lid() - await self._driver.disconnect() - self._driver = None + await self.driver.disconnect() + self.driver = None async def open_lid(self): - assert self._driver is not None - await self._driver.open_lid() + assert self.driver is not None + await self.driver.open_lid() async def close_lid(self): - assert self._driver is not None - await self._driver.close_lid() + assert self.driver is not None + await self.driver.close_lid() async def lift_plate(self): """Lift the thermocycler plate to un-stick and robustly pick up with robot arm.""" - assert self._driver is not None - await self._driver.lift_plate() + assert self.driver is not None + await self.driver.lift_plate() async def jog_lid(self, angle: float): """Jog the lid to a specific angle position.""" - assert self._driver is not None - await self._driver.jog_lid(angle) + assert self.driver is not None + await self.driver.jog_lid(angle) async def set_block_temperature(self, temperature: List[float]): """Set block temperature in °C. Only single unique temperature supported. @@ -244,8 +244,8 @@ async def set_block_temperature(self, temperature: List[float]): f"Opentrons thermocycler only supports a single unique block temperature, got {set(temperature)}" ) temp_value = temperature[0] - assert self._driver is not None - await self._driver.set_plate_temperature(temp_value) + assert self.driver is not None + await self.driver.set_plate_temperature(temp_value) async def set_lid_temperature(self, temperature: List[float]): """Set lid temperature in °C. Only single unique temperature supported.""" @@ -254,68 +254,68 @@ async def set_lid_temperature(self, temperature: List[float]): f"Opentrons thermocycler only supports a single unique lid temperature, got {set(temperature)}" ) temp_value = temperature[0] - assert self._driver is not None - await self._driver.set_lid_temperature(temp_value) + assert self.driver is not None + await self.driver.set_lid_temperature(temp_value) async def set_ramp_rate(self, ramp_rate: float): """Set the temperature ramp rate in °C/minute.""" - assert self._driver is not None - await self._driver.set_ramp_rate(ramp_rate) + assert self.driver is not None + await self.driver.set_ramp_rate(ramp_rate) async def deactivate_block(self): """Deactivate the block heater.""" - assert self._driver is not None - await self._driver.deactivate_block() + assert self.driver is not None + await self.driver.deactivate_block() async def deactivate_lid(self): """Deactivate the lid heater.""" - assert self._driver is not None - await self._driver.deactivate_lid() + assert self.driver is not None + await self.driver.deactivate_lid() async def get_device_info(self) -> dict: - assert self._driver is not None - return await self._driver.get_device_info() # type: ignore + assert self.driver is not None + return await self.driver.get_device_info() # type: ignore async def get_block_current_temperature(self) -> List[float]: - assert self._driver is not None - plate_temp = await self._driver.get_plate_temperature() + assert self.driver is not None + plate_temp = await self.driver.get_plate_temperature() return [plate_temp.current] async def get_block_target_temperature(self) -> List[float]: - assert self._driver is not None - plate_temp = await self._driver.get_plate_temperature() + assert self.driver is not None + plate_temp = await self.driver.get_plate_temperature() if plate_temp.target is not None: return [plate_temp.target] raise RuntimeError("Block target temperature is not set.") async def get_lid_current_temperature(self) -> List[float]: - assert self._driver is not None - lid_temp = await self._driver.get_lid_temperature() + assert self.driver is not None + lid_temp = await self.driver.get_lid_temperature() return [lid_temp.current] async def get_lid_target_temperature(self) -> List[float]: - assert self._driver is not None - lid_temp = await self._driver.get_lid_temperature() + assert self.driver is not None + lid_temp = await self.driver.get_lid_temperature() if lid_temp.target is not None: return [lid_temp.target] raise RuntimeError("Lid target temperature is not set.") async def get_lid_open(self) -> bool: """Return True if the lid is open.""" - assert self._driver is not None - lid_status = await self._driver.get_lid_status() + assert self.driver is not None + lid_status = await self.driver.get_lid_status() return lid_status == ThermocyclerLidStatus.OPEN # type: ignore async def get_lid_status(self) -> LidStatus: - assert self._driver is not None - lid_temp = await self._driver.get_lid_temperature() + assert self.driver is not None + lid_temp = await self.driver.get_lid_temperature() if lid_temp.target is not None and abs(lid_temp.current - lid_temp.target) < 1.0: return LidStatus.HOLDING_AT_TARGET return LidStatus.IDLE async def get_block_status(self) -> BlockStatus: - assert self._driver is not None - plate_temp = await self._driver.get_plate_temperature() + assert self.driver is not None + plate_temp = await self.driver.get_plate_temperature() if plate_temp.target is not None and abs(plate_temp.current - plate_temp.target) < 1.0: return BlockStatus.HOLDING_AT_TARGET return BlockStatus.IDLE 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..79aac61d343 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( 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 96% rename from pylabrobot/thermocycling/thermocycler_tests.py rename to pylabrobot/legacy/thermocycling/thermocycler_tests.py index 157d6762358..b63ba8e6f47 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: @@ -52,7 +52,7 @@ def __init__(self, *args, **kwargs): def test_thermocycler_serialization(self): """Test that the high-level resource serializes and deserializes correctly.""" - self.tc.backend = ThermocyclerChatterboxBackend() + self.tc._backend = ThermocyclerChatterboxBackend() serialized = self.tc.serialize() deserialized = Thermocycler.deserialize(serialized) assert self.tc == deserialized diff --git a/pylabrobot/legacy/tilting/__init__.py b/pylabrobot/legacy/tilting/__init__.py new file mode 100644 index 00000000000..70e4e3ed688 --- /dev/null +++ b/pylabrobot/legacy/tilting/__init__.py @@ -0,0 +1,6 @@ +"""Legacy. Use pylabrobot.capabilities.tilting and pylabrobot.hamilton.tilt_module instead.""" + +from .hamilton import HamiltonTiltModule +from .hamilton_backend import HamiltonTiltModuleDriver, HamiltonTiltModuleTilterBackend +from .tilter import Tilter +from .tilter_backend import TilterBackend diff --git a/pylabrobot/legacy/tilting/chatterbox.py b/pylabrobot/legacy/tilting/chatterbox.py new file mode 100644 index 00000000000..9c8d48493a0 --- /dev/null +++ b/pylabrobot/legacy/tilting/chatterbox.py @@ -0,0 +1,14 @@ +"""Legacy. Use pylabrobot.capabilities.tilting instead.""" + +from pylabrobot.capabilities.tilting import TilterBackend + + +class TilterChatterboxBackend(TilterBackend): + async def setup(self): + pass + + async def stop(self): + pass + + async def set_angle(self, angle: float): + pass diff --git a/pylabrobot/legacy/tilting/hamilton.py b/pylabrobot/legacy/tilting/hamilton.py new file mode 100644 index 00000000000..614d2c6444d --- /dev/null +++ b/pylabrobot/legacy/tilting/hamilton.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.hamilton.tilt_module.HamiltonTiltModule instead.""" + +from pylabrobot.hamilton.tilt_module import HamiltonTiltModule # noqa: F401 diff --git a/pylabrobot/legacy/tilting/hamilton_backend.py b/pylabrobot/legacy/tilting/hamilton_backend.py new file mode 100644 index 00000000000..ab46f891605 --- /dev/null +++ b/pylabrobot/legacy/tilting/hamilton_backend.py @@ -0,0 +1,8 @@ +"""Legacy. Use pylabrobot.hamilton.tilt_module instead.""" + +from pylabrobot.capabilities.tilting.backend import TiltModuleError # noqa: F401 +from pylabrobot.hamilton.tilt_module.backend import ( # noqa: F401 + HamiltonTiltModuleChatterboxTilterBackend, + HamiltonTiltModuleDriver, + HamiltonTiltModuleTilterBackend, +) diff --git a/pylabrobot/tilting/tilter.py b/pylabrobot/legacy/tilting/tilter.py similarity index 52% rename from pylabrobot/tilting/tilter.py rename to pylabrobot/legacy/tilting/tilter.py index c833181186e..17aee566a67 100644 --- a/pylabrobot/tilting/tilter.py +++ b/pylabrobot/legacy/tilting/tilter.py @@ -1,16 +1,33 @@ +"""Legacy. Use a vendor-specific machine class (e.g. HamiltonTiltModule) instead.""" + import math from typing import List, Optional -from pylabrobot.machines import Machine +from pylabrobot.capabilities.tilting import Tilter as _NewTilter +from pylabrobot.capabilities.tilting import TilterBackend as _NewTilterBackend +from pylabrobot.legacy.machines import Machine +from pylabrobot.legacy.tilting.tilter_backend import TilterBackend from pylabrobot.resources import Coordinate, Plate from pylabrobot.resources.resource_holder import ResourceHolder from pylabrobot.resources.well import CrossSectionType, Well -from .tilter_backend import TilterBackend + +class _TiltingAdapter(_NewTilterBackend): + def __init__(self, legacy: TilterBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + async def set_angle(self, angle: float): + await self._legacy.set_angle(angle) class Tilter(ResourceHolder, Machine): - """Resources that tilt plates.""" + """Legacy tilt module machine. In new code, use the vendor-specific machine class.""" def __init__( self, @@ -35,86 +52,57 @@ def __init__( child_location=child_location, ) Machine.__init__(self, backend=backend) - self.backend: TilterBackend = backend # fix type - self._absolute_angle: float = 0 + self.backend: TilterBackend = backend self._hinge_coordinate = hinge_coordinate + self.tilting = _NewTilter(backend=_TiltingAdapter(backend)) + self._capabilities = [self.tilting] + @property def absolute_angle(self) -> float: - return self._absolute_angle + return self.tilting.absolute_angle @property def hinge_coordinate(self) -> Coordinate: return self._hinge_coordinate async def set_angle(self, absolute_angle: float): - """Set the tilt module to rotate to a given angle. - - Args: - absolute_angle: The absolute (unsigned) angle to set rotation to, in degrees, measured from - horizontal as zero. - """ + await self.tilting.set_angle(absolute_angle) - await self.backend.set_angle(angle=absolute_angle) - self._absolute_angle = absolute_angle + async def tilt(self, relative_angle: float): + await self.tilting.tilt(relative_angle) def experimental_rotate_coordinate_around_hinge( self, absolute_coordinate: Coordinate, angle: float ) -> Coordinate: - """Rotate an absolute coordinate around the hinge of the tilter by a given angle. - - Args: - absolute_coordinate: The coordinate to rotate. - angle: The angle to rotate by, in degrees. Negative is clockwise. - - Returns: - Coordinate: The new coordinate after rotation. - """ theta = math.radians(angle) + origin = self.get_absolute_location("l", "f", "b") - rotation_arm_x = absolute_coordinate.x - ( - self._hinge_coordinate.x + self.get_absolute_location("l", "f", "b").x - ) - rotation_arm_z = absolute_coordinate.z - ( - self._hinge_coordinate.z + self.get_absolute_location("l", "f", "b").z - ) + rotation_arm_x = absolute_coordinate.x - (self._hinge_coordinate.x + origin.x) + rotation_arm_z = absolute_coordinate.z - (self._hinge_coordinate.z + origin.z) x_prime = rotation_arm_x * math.cos(theta) - rotation_arm_z * math.sin(theta) z_prime = rotation_arm_x * math.sin(theta) + rotation_arm_z * math.cos(theta) - new_x = x_prime + (self._hinge_coordinate.x + self.get_absolute_location("l", "f", "b").x) - new_z = z_prime + (self._hinge_coordinate.z + self.get_absolute_location("l", "f", "b").z) + new_x = x_prime + (self._hinge_coordinate.x + origin.x) + new_z = z_prime + (self._hinge_coordinate.z + origin.z) return Coordinate(new_x, absolute_coordinate.y, new_z) def experimental_get_plate_drain_offsets( self, plate: Plate, absolute_angle: Optional[float] = None ) -> List[Coordinate]: - """Get the drain edge offsets for all wells in the given plate, tilted around the hinge at a - given absolute angle. - - Args: - plate: The plate to calculate the offsets for. - absolute_angle: The absolute angle to rotate the plate. If `None`, the current tilt angle. - """ - if absolute_angle is None: - absolute_angle = self._absolute_angle - assert absolute_angle is not None # mypy + absolute_angle = self.tilting.absolute_angle angle = absolute_angle if self._hinge_coordinate.x < self._size_x / 2 else -absolute_angle - - _hinge_side = "l" if self._hinge_coordinate.x < self._size_x / 2 else "r" + hinge_side = "l" if self._hinge_coordinate.x < self._size_x / 2 else "r" well_drain_offsets = [] for well in plate.children: - level_absolute_well_drain_coordinate = well.get_absolute_location(_hinge_side, "c", "b") - rotated_absolute_well_drain_coordinate = self.experimental_rotate_coordinate_around_hinge( - level_absolute_well_drain_coordinate, angle - ) - well_drain_offset = rotated_absolute_well_drain_coordinate - well.get_absolute_location( - "c", "c", "b" - ) - well_drain_offsets.append(well_drain_offset) + level_coord = well.get_absolute_location(hinge_side, "c", "b") + rotated_coord = self.experimental_rotate_coordinate_around_hinge(level_coord, angle) + offset = rotated_coord - well.get_absolute_location("c", "c", "b") + well_drain_offsets.append(offset) return well_drain_offsets @@ -124,21 +112,8 @@ def experimental_get_well_drain_offsets( n_tips: int = 1, absolute_angle: Optional[float] = None, ) -> List[Coordinate]: - """Get the drain edge offsets for the given wells, tilted around the hinge at a - given absolute angle, for multiple tips. - - Args: - wells: The wells to calculate the offsets for. - n_tips: The number of tips to calculate offsets for. Defaults to 1. - absolute_angle: The absolute angle to rotate the wells. If `None`, the current tilt angle. - - Returns: - A list of lists of Coordinates, where each inner list contains the offsets for n_tips. - """ - if absolute_angle is None: - absolute_angle = self._absolute_angle - assert absolute_angle is not None # mypy + absolute_angle = self.tilting.absolute_angle angle = absolute_angle * (-1 if self._hinge_coordinate.x >= self._size_x / 2 else 1) hinge_on_left = self._hinge_coordinate.x < self._size_x / 2 @@ -150,24 +125,20 @@ def experimental_get_well_drain_offsets( "Wells must have circular cross-section" ) - diameter = well.get_absolute_size_x() # assuming circular well + diameter = well.get_absolute_size_x() radius = diameter / 2 if n_tips > 1: assert (n_tips - 1) * min_tip_distance <= diameter, ( f"Cannot fit {n_tips} tips in a well with diameter {diameter} mm" ) - y_offsets = [ ((n_tips - 1) / 2 - tip_index) * min_tip_distance for tip_index in range(n_tips) ] - x_offset = math.sqrt(radius**2 - max(y_offsets) ** 2) x_offset = -x_offset if hinge_on_left else x_offset - tip_coords = [Coordinate(x_offset, y, 0) for y in y_offsets] else: - # Default case: n_tips = 1 x_offset = -radius if hinge_on_left else radius tip_coords = [Coordinate(x_offset, 0, 0)] @@ -183,11 +154,3 @@ def experimental_get_well_drain_offsets( well_drain_offsets.append(offsets) return [offset for well_offsets in well_drain_offsets for offset in well_offsets] - - async def tilt(self, relative_angle: float): - """Tilt the plate contained in the tilt module by a given angle relative to the current angle. - - Args: - relative_angle: The angle to rotate by, in degrees. Clockwise. 0 is horizontal. - """ - await self.set_angle(self._absolute_angle + relative_angle) diff --git a/pylabrobot/tilting/tilter_backend.py b/pylabrobot/legacy/tilting/tilter_backend.py similarity index 68% rename from pylabrobot/tilting/tilter_backend.py rename to pylabrobot/legacy/tilting/tilter_backend.py index 5b2b9653dc3..22996017e3c 100644 --- a/pylabrobot/tilting/tilter_backend.py +++ b/pylabrobot/legacy/tilting/tilter_backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.tilting instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.machine import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class TiltModuleError(Exception): @@ -14,9 +16,6 @@ class TilterBackend(MachineBackend, metaclass=ABCMeta): async def set_angle(self, angle: float): """Set the tilt module to rotate by a given angle. - We assume the rotation anchor is the right side of the module. This may change in the future - if we integrate other tilt modules. - Args: angle: The angle to rotate by, in degrees. Clockwise. 0 is horizontal. """ diff --git a/pylabrobot/liconic/__init__.py b/pylabrobot/liconic/__init__.py new file mode 100644 index 00000000000..8fda2ba99f3 --- /dev/null +++ b/pylabrobot/liconic/__init__.py @@ -0,0 +1,3 @@ +from .backend import LiconicBackend +from .constants import LiconicType +from .liconic import Liconic diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/liconic/backend.py similarity index 52% rename from pylabrobot/storage/liconic/liconic_backend.py rename to pylabrobot/liconic/backend.py index f9b770f727f..ddd0cbcd581 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/liconic/backend.py @@ -13,14 +13,20 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.barcode_scanners import BarcodeScanner +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial 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 + +from .constants import ControllerError, HandlingError, LiconicType +from .errors import controller_error_map, handler_error_map logger = logging.getLogger(__name__) @@ -41,11 +47,15 @@ } -class ExperimentalLiconicBackend(IncubatorBackend): - """Backend for Liconic incubators. - - Optionally accepts a BarcodeScanner instance for internal barcode reading. - """ +class LiconicBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + ShakerBackend, + HasContinuousShaking, + Driver, +): + """Backend for Liconic incubators.""" default_baud = 9600 serial_message_encoding = "ascii" @@ -57,7 +67,6 @@ def __init__( self, model: Union[LiconicType, str], port: str, - barcode_scanner: Optional[BarcodeScanner] = None, ): if not HAS_SERIAL: raise RuntimeError( @@ -66,8 +75,6 @@ def __init__( ) super().__init__() - self.barcode_scanner = barcode_scanner - if isinstance(model, str): try: model = LiconicType(model) @@ -92,21 +99,14 @@ def __init__( self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None - # Function to setup serial connection with Liconic PLC - async def setup(self): - """ - 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. - 2. Send >200 ms break, wait 150 ms, flush buffers. - 3. Handshake: CR → wait for CC - 4. Activate handling: ST 1801 → expect OK - 5. Poll ready-flag: RD 1915 → wait for "1" - """ + async def setup(self, backend_params: Optional[BackendParams] = None): + await Driver.setup(self, backend_params=backend_params) try: await self.io.setup() except serial.SerialException as e: raise RuntimeError(f"Could not open {self.io.port}: {e}") from e - await self.io.send_break(duration=0.2) # >100 ms required + await self.io.send_break(duration=0.2) await asyncio.sleep(0.15) await self.io.reset_input_buffer() await self.io.reset_output_buffer() @@ -114,7 +114,7 @@ async def setup(self): await self.io.write(b"CR\r") deadline = time.time() + self.init_timeout while time.time() < deadline: - resp = await self.io.readline() # reads through LF + resp = await self.io.readline() if resp.strip() == b"CC": break else: @@ -132,170 +132,193 @@ async def setup(self): await self.io.write(b"RD 1915\r") flag = await self.io.readline() if flag.strip() == b"1": - break + logger.info("[Liconic %s] connected", self.io.port) + return await asyncio.sleep(self.poll_interval) - else: - await self.io.stop() - raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") - - def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: - rack = site.parent - assert isinstance(rack, PlateCarrier), "Site not in rack" - assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, liconic is 1-indexed - site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed - return rack_idx, site_idx - # Wrote this function to return motor step size and plate position number from PlateCarrier model name - def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: - rack = site.parent - assert isinstance(rack, PlateCarrier), "Site not in rack" - assert self._racks is not None, "Racks not set" - if rack.model is None or not rack.model.startswith("liconic"): - raise ValueError(f"The plate carrier used: {rack.model} is not compatible with the Liconic") - match = re.search(r"_(\d+)mm", rack.model) - if match: - site_height = int(match.group(1)) - site_num = int(rack.model.split("_")[-1]) - if site_height not in LICONIC_SITE_HEIGHT_TO_STEPS: - raise ValueError( - f"Unknown site height {site_height}mm - not in LICONIC_SITE_HEIGHT_TO_STEPS" - ) - return LICONIC_SITE_HEIGHT_TO_STEPS[site_height], site_num - raise ValueError( - f"Could not parse site height and pos num from PlateCarrier model: {rack.model}" - ) + await self.io.stop() + raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") async def stop(self): await self.io.stop() + await Driver.stop(self) async def set_racks(self, racks: List[PlateCarrier]): - await super().set_racks(racks) + self._racks = racks warnings.warn("Liconic racks need to be configured manually on each setup") - async def initialize(self): - await self._send_command("ST 1900") - await self._send_command("ST 1801") - await self._wait_ready() - - async def open_door(self): - await self._send_command("ST 1901") - await self._wait_ready() - - async def close_door(self): - await self._send_command("ST 1902") - await self._wait_ready() + # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray( - self, plate: Plate, read_barcode: bool = False, **backend_kwargs - ): - """Fetch a plate from the incubator to the loading tray.""" + async def fetch_plate_to_loading_tray(self, plate: Plate): site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command(f"WR DM0 {m}") # carousel number - await self._send_command(f"WR DM23 {step_size}") # motor step size - await self._send_command(f"WR DM25 {pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {n}") # plate position in carousel - - if read_barcode: - plate.barcode = await self.read_barcode_inline(m, n) + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM23 {step_size}") + await self._send_command(f"WR DM25 {pos_num}") + await self._send_command(f"WR DM5 {n}") - await self._send_command("ST 1905") # plate to transfer station + await self._send_command("ST 1905") await self._wait_ready() - await self._send_command("ST 1903") # terminate access + await self._send_command("ST 1903") + logger.info( + "[Liconic %s] fetch_plate_to_loading_tray: plate=%s slot=(%d,%d)", + self.io.port, + plate.name, + m, + n, + ) - async def take_in_plate( - self, plate: Plate, site: PlateHolder, read_barcode: bool = False, **backend_kwargs - ): - """Take in a plate from the loading tray to the incubator.""" + async def store_plate(self, plate: Plate, site: PlateHolder): m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command(f"WR DM0 {m}") # carousel number - await self._send_command(f"WR DM23 {step_size}") # motor step size - await self._send_command(f"WR DM25 {pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {n}") # plate position in cassette - await self._send_command("ST 1904") # plate from transfer station + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM23 {step_size}") + await self._send_command(f"WR DM25 {pos_num}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1904") await self._wait_ready() + await self._send_command("ST 1903") + logger.info( + "[Liconic %s] store_plate: plate=%s slot=(%d,%d)", + self.io.port, + plate.name, + m, + n, + ) - if read_barcode: - plate.barcode = await self.read_barcode_inline(m, n) + # -- TemperatureControllerBackend -- - await self._send_command("ST 1903") # terminate access + @property + def supports_active_cooling(self) -> bool: + return self.model.has_active_cooling - async def move_position_to_position( - self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False - ): - """Move plate from one internal position to another""" - orig_site = plate.parent - assert isinstance(orig_site, PlateHolder) - assert isinstance(dest_site, PlateHolder) + async def set_temperature(self, temperature: float): + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") + temp_value = int(temperature * 10) + temp_str = str(temp_value).zfill(5) + await self._send_command(f"WR DM890 {temp_str}") + await self._wait_ready() + logger.info("[Liconic %s] set_temperature: target=%.1f°C", self.io.port, temperature) - if dest_site.resource is not None: - raise RuntimeError(f"Position {dest_site} already has a plate assigned!") + async def request_current_temperature(self) -> float: + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command("RD DM982") + try: + temperature = int(resp) / 10.0 + except ValueError: + raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}") + logger.info( + "[Liconic %s] request_current_temperature: measured=%.1f°C", self.io.port, temperature + ) + return temperature - orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # - dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # + async def deactivate(self): + pass # no-op - await self._send_command(f"WR DM0 {orig_m}") # origin cassette # - orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) - dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) + # -- HumidityControllerBackend -- - await self._send_command(f"WR DM0 {orig_m}") # carousel number - await self._send_command(f"WR DM23 {orig_step_size}") # motor step size - await self._send_command(f"WR DM25 {orig_pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {orig_n}") # origin plate position # + @property + def supports_humidity_control(self) -> bool: + return self.model.has_humidity_control - if read_barcode: - plate.barcode = await self.read_barcode_inline(orig_m, orig_n) + async def set_humidity(self, humidity: float): + if not self.model.has_humidity_control: + raise NotImplementedError("Climate control is not supported on this model") + humidity_val = int(humidity * 1000) + await self._send_command(f"WR DM893 {str(humidity_val).zfill(5)}") + await self._wait_ready() + logger.info("[Liconic %s] set_humidity: target=%.1f%%", self.io.port, humidity) + + async def request_current_humidity(self) -> float: + if not self.model.has_humidity_control: + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command("RD DM983") + try: + humidity = int(resp) / 1000.0 + except ValueError: + raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}") + logger.info("[Liconic %s] request_current_humidity: measured=%.1f%%", self.io.port, humidity) + return humidity - await self._send_command("ST 1908") # pick plate from origin position + # -- ShakerBackend -- - await self._wait_ready() + @property + def supports_locking(self) -> bool: + return False - if orig_m != dest_m: - await self._send_command(f"WR DM0 {dest_m}") # destination cassette # if different - await self._send_command(f"WR DM23 {dest_step_size}") # motor step size - await self._send_command(f"WR DM25 {dest_pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {dest_n}") # destination plate position # - await self._send_command("ST 1909") # place plate in destination position + async def lock_plate(self): + raise NotImplementedError("Liconic does not support plate locking") - await self._wait_ready() - await self._send_command("ST 1903") # terminate access + async def unlock_plate(self): + raise NotImplementedError("Liconic does not support plate locking") - async def read_barcode_inline(self, cassette: int, plt_position: int) -> Barcode: - if self.barcode_scanner is None: - raise RuntimeError("Barcode scanner not configured for this incubator instance") + async def shake(self, speed: float, duration: float, backend_params=None): + await self.start_shaking(speed=speed) + try: + await asyncio.sleep(duration) + finally: + await self.stop_shaking() - await self._send_command("ST 1910") # move shovel to barcode reading position + async def start_shaking(self, speed: float): + if speed < 1.0 or speed > 50.0: + raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") + frequency_value = int(speed * 10) + await self._send_command(f"WR DM39 {str(frequency_value).zfill(5)}") + await self._send_command("ST 1913") await self._wait_ready() - barcode = await self.barcode_scanner.scan() - logger.info( - f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode.data}" - ) - reset = await self._send_command("RS 1910") # move shovel back to normal position - if reset != "OK": - raise RuntimeError("Failed to reset shovel position after barcode reading") + logger.info("[Liconic %s] start_shaking: speed=%.1fHz", self.io.port, speed) + + async def stop_shaking(self): + await self._send_command("RS 1913") await self._wait_ready() - return barcode + logger.info("[Liconic %s] stop_shaking", self.io.port) + + # -- Device-specific methods -- + + def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + assert isinstance(rack, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + rack_idx = self._racks.index(rack) + 1 + site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 + return rack_idx, site_idx + + def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + assert isinstance(rack, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + if rack.model is None or not rack.model.startswith("liconic"): + raise ValueError(f"The plate carrier used: {rack.model} is not compatible with the Liconic") + match = re.search(r"_(\d+)mm", rack.model) + if match: + site_height = int(match.group(1)) + site_num = int(rack.model.split("_")[-1]) + if site_height not in LICONIC_SITE_HEIGHT_TO_STEPS: + raise ValueError( + f"Unknown site height {site_height}mm - not in LICONIC_SITE_HEIGHT_TO_STEPS" + ) + return LICONIC_SITE_HEIGHT_TO_STEPS[site_height], site_num + raise ValueError( + f"Could not parse site height and pos num from PlateCarrier model: {rack.model}" + ) async def _send_command(self, command: str) -> str: - """ - Send an ASCII command to the Liconic PLC over serial and return the response. - """ cmd = command.strip() + "\r" - logger.debug(f"Sending command to Liconic PLC: {cmd!r}") + logger.debug("Sending command to Liconic PLC: %r", cmd) await self.io.write(cmd.encode(self.serial_message_encoding)) resp = (await self.io.read(128)).decode(self.serial_message_encoding) if not resp: raise RuntimeError(f"No response from Liconic PLC for command {command!r}") resp = resp.strip() if resp.startswith("E"): - logger.error(f"Command {command} failed with {resp}") + logger.error("Command %s failed with %s", command, resp) for member in ControllerError: if resp == member.value: cls, msg = controller_error_map[member] @@ -304,9 +327,6 @@ async def _send_command(self, command: str) -> str: return resp async def _wait_plate_ready(self, timeout: int = 60): - """ - Poll the plate-ready flag (RD 1914) until it is set, or timeout is reached. - """ start = time.time() deadline = start + timeout while time.time() < deadline: @@ -317,10 +337,6 @@ async def _wait_plate_ready(self, timeout: int = 60): raise TimeoutError(f"Plate did not become ready within {timeout} seconds") async def _wait_ready(self, timeout: int = 60): - """ - Poll the ready-flag (RD 1915) until it is set. If timeout is reached - the error flag is read and if true aka "1" then the error register is read. - """ start = time.time() deadline = start + timeout while time.time() < deadline: @@ -338,183 +354,143 @@ async def _wait_ready(self, timeout: int = 60): raise RuntimeError(f"Liconic Handler in unknown error state with memory showing {error}") raise TimeoutError(f"Incubator did not become ready within {timeout} seconds") - async def set_temperature(self, temperature: float): - """Set the temperature of the incubator in degrees Celsius. Using command WR DM890 ttttt - where ttttt is temperature in 0.1 degrees Celsius (e.g. 37.0C = 370)""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") - - temp_value = int(temperature * 10) - temp_str = str(temp_value).zfill(5) - await self._send_command(f"WR DM890 {temp_str}") + async def initialize(self): + await self._send_command("ST 1900") + await self._send_command("ST 1801") await self._wait_ready() - async def get_temperature(self) -> float: - """Get the temperature of the incubator in degrees Celsius. Using command RD DM982""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") + async def open_door(self): + await self._send_command("ST 1901") + await self._wait_ready() - resp = await self._send_command("RD DM982") - try: - temp_value = int(resp) - temperature = temp_value / 10.0 - return temperature - except ValueError: - raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}") + async def close_door(self): + await self._send_command("ST 1902") + await self._wait_ready() - async def shaker_status(self) -> int: - """Determines whether the shaker is ON (1) or OFF (0). + async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder): + orig_site = plate.parent + assert isinstance(orig_site, PlateHolder) + assert isinstance(dest_site, PlateHolder) - UNTESTED. Unsure if 1 means ON and 0 means OFF, needs to be confirmed.""" - # TODO: Missing PLC command - need to determine correct command from Liconic documentation - raise NotImplementedError("shaker_status command not yet implemented") + if dest_site.resource is not None: + raise RuntimeError(f"Position {dest_site} already has a plate assigned!") - async def get_shaker_speed(self) -> float: - """Gets the current shaker speed in Hz, default = 25. + orig_m, orig_n = self._site_to_m_n(orig_site) + dest_m, dest_n = self._site_to_m_n(dest_site) + orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) + dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) - UNTESTED. Unsure if Liconic returns 00250 for 25 or 00025. Assuming former.""" - speed_val = await self._send_command("RD DM39") - speed = int(speed_val) / 10.0 + await self._send_command(f"WR DM0 {orig_m}") + await self._send_command(f"WR DM23 {orig_step_size}") + await self._send_command(f"WR DM25 {orig_pos_num}") + await self._send_command(f"WR DM5 {orig_n}") + await self._send_command("ST 1908") await self._wait_ready() - return speed - async def start_shaking(self, frequency): - """Start shaking. Must be between 1 and 50 Hz. Frequency by default is 10 Hz. Uses command - ST 1913. - - UNTESTED. Unsure if WR DM39 00250 sets 25 Hz or if WR DM39 00025 does. Assuming former.""" - if frequency < 1.0 or frequency > 50.0: - raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") - frequency_value = int(frequency * 10) # PLC expects 0.1 Hz units: 25 Hz -> 250 - await self._send_command(f"WR DM39 {str(frequency_value).zfill(5)}") - await self._send_command("ST 1913") + if orig_m != dest_m: + await self._send_command(f"WR DM0 {dest_m}") + await self._send_command(f"WR DM23 {dest_step_size}") + await self._send_command(f"WR DM25 {dest_pos_num}") + await self._send_command(f"WR DM5 {dest_n}") + await self._send_command("ST 1909") await self._wait_ready() + await self._send_command("ST 1903") - async def stop_shaking(self): - """Stop shaking. Uses command RS 1913. - - UNTESTED.""" - await self._send_command("RS 1913") + async def read_barcode_inline( + self, cassette: int, plt_position: int, barcode_scanner: BarcodeScannerBackend + ) -> Optional[Barcode]: + await self._send_command("ST 1910") await self._wait_ready() + barcode = await barcode_scanner.scan_barcode() + logger.info( + "Read barcode from plate at cassette %d, position %d: %s", + cassette, + plt_position, + barcode, + ) + reset = await self._send_command("RS 1910") + if reset != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") + await self._wait_ready() + return barcode - async def get_target_temperature(self) -> float: - """Get the set value temperature of the incubator in degrees Celsius.""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") + async def scan_barcode( + self, site: PlateHolder, barcode_scanner: BarcodeScannerBackend + ) -> Optional[Barcode]: + m, n = self._site_to_m_n(site) + step_size, pos_num = self._carrier_to_steps_pos(site) + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM23 {step_size}") + await self._send_command(f"WR DM25 {pos_num}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1910") + barcode = await barcode_scanner.scan_barcode() + logger.info("Scanned barcode: %s", barcode) + return barcode + async def request_target_temperature(self) -> float: + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command("RD DM890") try: - temp_value = int(resp) - temperature = temp_value / 10.0 - return temperature + return int(resp) / 10.0 except ValueError: raise RuntimeError(f"Invalid set temperature value received from incubator: {resp!r}") - async def set_humidity(self, humidity: float): - """Set the humidity of the incubator as a fraction (0.0 to 1.0).""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") - - humidity_val = int(humidity * 1000) # PLC uses 0.1% units: 0.9 fraction -> 900 -> 90.0% - await self._send_command(f"WR DM893 {str(humidity_val).zfill(5)}") - await self._wait_ready() - - async def get_humidity(self) -> float: - """Get the actual humidity of the incubator as a fraction (0.0 to 1.0).""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") - - resp = await self._send_command("RD DM983") - try: - humidity_value = int(resp) - humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction - return humidity - except ValueError: - raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}") - - async def get_target_humidity(self) -> float: - """Get the set value humidity of the incubator as a fraction (0.0 to 1.0).""" - if self.model.value.split("_")[-1] == "NC": + async def request_target_humidity(self) -> float: + if not self.model.has_humidity_control: raise NotImplementedError("Climate control is not supported on this model") - resp = await self._send_command("RD DM893") try: - humidity_value = int(resp) - humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction - return humidity + return int(resp) / 1000.0 except ValueError: raise RuntimeError(f"Invalid set humidity value received from incubator: {resp!r}") - async def set_co2_level(self, co2_level: float): - """Set the CO2 level of the incubator as a fraction (0.0 to 1.0). PLC uses 1/100% vol units - (e.g. 500 = 5.0%), so 0.05 fraction -> 500. + async def request_shaker_speed(self) -> float: + speed_val = await self._send_command("RD DM39") + speed = int(speed_val) / 10.0 + await self._wait_ready() + return speed - UNTESTED.""" - co2_val = int(co2_level * 10000) # PLC uses 0.01% units: 0.05 fraction -> 500 -> 5.0% + async def set_co2_level(self, co2_level: float): + co2_val = int(co2_level * 10000) await self._send_command(f"WR DM894 {str(co2_val).zfill(5)}") await self._wait_ready() - async def get_co2_level(self) -> float: - """Get the CO2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" + async def request_co2_level(self) -> float: resp = await self._send_command("RD DM984") try: - co2_value = int(resp) - co2 = co2_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction - return co2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}") - async def get_target_co2_level(self) -> float: - """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" + async def request_target_co2_level(self) -> float: resp = await self._send_command("RD DM894") try: - co2_set_value = int(resp) - co2 = co2_set_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction - return co2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid co2 set value received from incubator: {resp!r}") async def set_n2_level(self, n2_level: float): - """Set the N2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" - n2_val = int(n2_level * 10000) # PLC uses 0.01% units: 0.9 fraction -> 9000 -> 90.0% + n2_val = int(n2_level * 10000) await self._send_command(f"WR DM895 {str(n2_val).zfill(5)}") await self._wait_ready() - async def get_n2_level(self) -> float: - """Get the N2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" + async def request_n2_level(self) -> float: resp = await self._send_command("RD DM985") try: - n2_value = int(resp) - n2 = n2_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction - return n2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}") - async def get_target_n2_level(self) -> float: - """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" + async def request_target_n2_level(self) -> float: resp = await self._send_command("RD DM895") try: - n2_set_value = int(resp) - n2 = n2_set_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction - return n2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid N2 set value received from incubator: {resp!r}") async def turn_swap_station(self, home: bool): - """Turn the swap station of the incubator. If home is True, turn to home position. - - UNTESTED. Unsure what RD 1912 returns (is 1 home or swapped?). Another avenue is to read the - first byte of T16 or T17 but don't have ability to test.""" resp = await self._send_command("RD 1912") if home and resp == "1": await self._send_command("RS 1912") @@ -522,10 +498,6 @@ async def turn_swap_station(self, home: bool): await self._send_command("ST 1912") async def check_shovel_sensor(self) -> bool: - """Activate shovel transfer sensor (ST 1911, off by default on HT units), wait 0.1 seconds, - then check if the shovel plate sensor is activated. - - UNTESTED.""" await self._send_command("ST 1911") await asyncio.sleep(0.1) resp = await self._send_command("RD 1812") @@ -537,9 +509,6 @@ async def check_shovel_sensor(self) -> bool: raise RuntimeError(f"Unexpected response from incubator read shovel sensor: {resp!r}") async def check_transfer_sensor(self) -> bool: - """Check if the transfer plate sensor is activated. - - UNTESTED.""" resp = await self._send_command("RD 1813") if resp == "1": return True @@ -549,9 +518,6 @@ async def check_transfer_sensor(self) -> bool: raise RuntimeError(f"Unexpected response from read transfer station sensor: {resp!r}") async def check_second_transfer_sensor(self) -> bool: - """Check if the second transfer plate sensor is activated. - - UNTESTED.""" resp = await self._send_command("RD 1807") if resp == "1": return True @@ -560,27 +526,9 @@ async def check_second_transfer_sensor(self) -> bool: else: raise RuntimeError(f"Unexpected response from read 2nd transfer station sensor: {resp!r}") - async def scan_barcode(self, site: PlateHolder) -> Barcode: - """Scan a barcode using the internal barcode reader.""" - if self.barcode_scanner is None: - raise RuntimeError("Barcode scanner not configured for this incubator instance") - - m, n = self._site_to_m_n(site) - step_size, pos_num = self._carrier_to_steps_pos(site) - - await self._send_command(f"WR DM0 {m}") # carousel number - await self._send_command(f"WR DM23 {step_size}") # pitch of plate in mm - await self._send_command(f"WR DM25 {pos_num}") # plate - await self._send_command(f"WR DM5 {n}") # plate position in carousel - await self._send_command("ST 1910") # move shovel to barcode reading position - - barcode = await self.barcode_scanner.scan() - logger.info(f"Scanned barcode: {barcode.data}") - return barcode - def serialize(self) -> dict: return { - **super().serialize(), + **Driver.serialize(self), "port": self.io.port, "model": self.model.value, } diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/liconic/constants.py similarity index 67% rename from pylabrobot/storage/liconic/constants.py rename to pylabrobot/liconic/constants.py index b9ae563a061..588de789560 100644 --- a/pylabrobot/storage/liconic/constants.py +++ b/pylabrobot/liconic/constants.py @@ -2,65 +2,82 @@ class LiconicType(Enum): - STX44_IC = "STX44_IC" # incubator - STX44_HC = "STX44_HC" # humid cooler - STX44_DC2 = "STX44_DC2" # dry storage - STX44_HR = "STX44_HR" # humid wide range - STX44_DR2 = "STX44_DR2" # dry wide range - STX44_AR = "STX44_AR" # humidity controlled - STX44_DF = "STX44_DF" # deep freezer - STX44_NC = "STX44_NC" # no climate - STX44_DH = "STX44_DH" # dry humid - - STX110_IC = "STX110_IC" # incubator - STX110_HC = "STX110_HC" # humid cooler - STX110_DC2 = "STX110_DC2" # dry storage - STX110_HR = "STX110_HR" # humid wide range - STX110_DR2 = "STX110_DR2" # dry wide range - STX110_AR = "STX110_AR" # humidity controlled - STX110_DF = "STX110_DF" # deep freezer - STX110_NC = "STX110_NC" # no climate - STX110_DH = "STX110_DH" # dry humid - - STX220_IC = "STX220_IC" # incubator - STX220_HC = "STX220_HC" # humid cooler - STX220_DC2 = "STX220_DC2" # dry storage - STX220_HR = "STX220_HR" # humid wide range - STX220_DR2 = "STX220_DR2" # dry wide range - STX220_AR = "STX220_AR" # humidity controlled - STX220_DF = "STX220_DF" # deep freezer - STX220_NC = "STX220_NC" # no climate - STX220_DH = "STX220_DH" # dry humid - - STX280_IC = "STX280_IC" # incubator - STX280_HC = "STX280_HC" # humid cooler - STX280_DC2 = "STX280_DC2" # dry storage - STX280_HR = "STX280_HR" # humid wide range - STX280_DR2 = "STX280_DR2" # dry wide range - STX280_AR = "STX280_AR" # humidity controlled - STX280_DF = "STX280_DF" # deep freezer - STX280_NC = "STX280_NC" # no climate - STX280_DH = "STX280_DH" # dry humid - - STX500_IC = "STX500_IC" # incubator - STX500_HC = "STX500_HC" # humid cooler - STX500_DC2 = "STX500_DC2" # dry storage - STX500_HR = "STX500_HR" # humid wide range - STX500_DR2 = "STX500_DR2" # dry wide range - STX500_AR = "STX500_AR" # humidity controlled - STX500_DF = "STX500_DF" # deep freezer - STX500_NC = "STX500_NC" # no climate - STX500_DH = "STX500_DH" # dry humid - - STX1000_IC = "STX1000_IC" # incubator - STX1000_HC = "STX1000_HC" # humid cooler - STX1000_DC2 = "STX1000_DC2" # dry storage - STX1000_HR = "STX1000_HR" # humid wide range - STX1000_DR2 = "STX1000_DR2" # dry wide range - STX1000_AR = "STX1000_AR" # humidity controlled - STX1000_DF = "STX1000_DF" # deep freezer - STX1000_NC = "STX1000_NC" # no climate - STX1000_DH = "STX1000_DH" # dry humid + STX44_IC = "STX44_IC" + STX44_HC = "STX44_HC" + STX44_DC2 = "STX44_DC2" + STX44_HR = "STX44_HR" + STX44_DR2 = "STX44_DR2" + STX44_AR = "STX44_AR" + STX44_DF = "STX44_DF" + STX44_NC = "STX44_NC" + STX44_DH = "STX44_DH" + + STX110_IC = "STX110_IC" + STX110_HC = "STX110_HC" + STX110_DC2 = "STX110_DC2" + STX110_HR = "STX110_HR" + STX110_DR2 = "STX110_DR2" + STX110_AR = "STX110_AR" + STX110_DF = "STX110_DF" + STX110_NC = "STX110_NC" + STX110_DH = "STX110_DH" + + STX220_IC = "STX220_IC" + STX220_HC = "STX220_HC" + STX220_DC2 = "STX220_DC2" + STX220_HR = "STX220_HR" + STX220_DR2 = "STX220_DR2" + STX220_AR = "STX220_AR" + STX220_DF = "STX220_DF" + STX220_NC = "STX220_NC" + STX220_DH = "STX220_DH" + + STX280_IC = "STX280_IC" + STX280_HC = "STX280_HC" + STX280_DC2 = "STX280_DC2" + STX280_HR = "STX280_HR" + STX280_DR2 = "STX280_DR2" + STX280_AR = "STX280_AR" + STX280_DF = "STX280_DF" + STX280_NC = "STX280_NC" + STX280_DH = "STX280_DH" + + STX500_IC = "STX500_IC" + STX500_HC = "STX500_HC" + STX500_DC2 = "STX500_DC2" + STX500_HR = "STX500_HR" + STX500_DR2 = "STX500_DR2" + STX500_AR = "STX500_AR" + STX500_DF = "STX500_DF" + STX500_NC = "STX500_NC" + STX500_DH = "STX500_DH" + + STX1000_IC = "STX1000_IC" + STX1000_HC = "STX1000_HC" + STX1000_DC2 = "STX1000_DC2" + STX1000_HR = "STX1000_HR" + STX1000_DR2 = "STX1000_DR2" + STX1000_AR = "STX1000_AR" + STX1000_DF = "STX1000_DF" + STX1000_NC = "STX1000_NC" + STX1000_DH = "STX1000_DH" + + @property + def climate_suffix(self) -> str: + return self.value.split("_")[-1] + + @property + def has_temperature_control(self) -> bool: + return self.climate_suffix != "NC" + + @property + def has_humidity_control(self) -> bool: + """Independent humidity control (not just temperature-dependent).""" + return self.climate_suffix in {"DC2", "DR2", "AR", "DH"} + + @property + def has_active_cooling(self) -> bool: + return self.climate_suffix in {"HC", "HR", "DF"} class ControllerError(Enum): diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/liconic/errors.py similarity index 99% rename from pylabrobot/storage/liconic/errors.py rename to pylabrobot/liconic/errors.py index 0025661ca97..7fed573e98d 100644 --- a/pylabrobot/storage/liconic/errors.py +++ b/pylabrobot/liconic/errors.py @@ -1,6 +1,6 @@ from typing import Dict, Tuple, Type -from pylabrobot.storage.liconic.constants import ControllerError, HandlingError +from pylabrobot.liconic.constants import ControllerError, HandlingError class LiconicControllerRelayError(Exception): diff --git a/pylabrobot/liconic/liconic.py b/pylabrobot/liconic/liconic.py new file mode 100644 index 00000000000..3d5e7e52e23 --- /dev/null +++ b/pylabrobot/liconic/liconic.py @@ -0,0 +1,207 @@ +import random +from typing import List, Literal, Optional, Union, cast + +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval +from pylabrobot.capabilities.barcode_scanning import BarcodeScanner +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling import HumidityController +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) + +from .backend import LiconicBackend, LiconicType + + +class NoFreeSiteError(Exception): + pass + + +class Liconic(Resource, Device): + def __init__( + self, + name: str, + liconic_model: Union[LiconicType, str], + port: str, + racks: List[PlateCarrier], + loading_tray_location: Coordinate, + has_shaker: bool = False, + barcode_scanner: Optional[BarcodeScanner] = None, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + if isinstance(liconic_model, str): + liconic_model = LiconicType(liconic_model) + + backend = LiconicBackend(model=liconic_model, port=port) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, driver=backend) + self.driver: LiconicBackend = backend + + self.loading_tray = PlateHolder( + name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + + self._racks = racks + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + self.retrieval = AutomatedRetrieval(backend=backend) + self.tc = ( + TemperatureController(backend=backend) if liconic_model.has_temperature_control else None + ) + self.humidity_controller = ( + HumidityController(backend=backend) if liconic_model.has_humidity_control else None + ) + self.shaker = Shaker(backend=backend) if has_shaker else None + self.barcode_scanner = barcode_scanner + + self._capabilities = [ + c + for c in [ + self.retrieval, + self.tc, + self.humidity_controller, + self.shaker, + self.barcode_scanner, + ] + if c is not None + ] + + @property + def racks(self) -> List[PlateCarrier]: + return self._racks + + async def setup(self, backend_params: Optional[BackendParams] = None, **backend_kwargs): + if self.barcode_scanner is not None: + await self.barcode_scanner.backend._on_setup() + await super().setup(backend_params=backend_params) + await self.driver.set_racks(self._racks) + + async def stop(self): + await super().stop() + if self.barcode_scanner is not None: + await self.barcode_scanner.backend._on_stop() + + def get_num_free_sites(self) -> int: + return sum(len(rack.get_free_sites()) for rack in self._racks) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name} not found in '{self.name}'") + + async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + site = self.get_site_by_plate_name(plate_name) + plate = site.resource + assert plate is not None + await self.retrieval.fetch_plate_to_loading_tray(plate) + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + def _plate_height(p: Plate): + if p.has_lid(): + return p.get_size_z() + 3 + return p.get_size_z() + + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= _plate_height(plate) + ] + if len(available) == 0: + raise NoFreeSiteError(f"No free site found in '{self.name}' for plate '{plate.name}'") + return sorted(available, key=lambda site: site.get_size_z()) + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + plate = cast(Plate, self.loading_tray.resource) + if plate is None: + raise ResourceNotFoundError(f"No plate on the loading tray of '{self.name}'") + + if site == "random": + site = self.find_random_site(plate) + elif site == "smallest": + site = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name} is not available for plate {plate.name}") + else: + raise ValueError(f"Invalid site: {site}") + await self.retrieval.store_plate(plate, site) + plate.unassign() + site.assign_child_resource(plate) + + def summary(self) -> str: + def create_pretty_table(header, *columns) -> str: + col_widths = [ + max(len(str(item)) for item in [header[i]] + list(columns[i])) for i in range(len(header)) + ] + + def format_row(row, border="|") -> str: + return ( + f"{border} " + + " | ".join(f"{str(row[i]).ljust(col_widths[i])}" for i in range(len(row))) + + f" {border}" + ) + + def separator_line(cross: str = "+", line: str = "-") -> str: + return cross + cross.join(line * (width + 2) for width in col_widths) + cross + + table = [] + table.append(separator_line()) + table.append(format_row(header)) + table.append(separator_line()) + for row in zip(*columns): + table.append(format_row(row)) + table.append(separator_line()) + return "\n".join(table) + + header = [f"Rack {i}" for i in range(len(self._racks))] + sites = [ + [site.resource.name if site.resource else "" for site in reversed(rack.sites.values())] + for rack in self._racks + ] + return create_pretty_table(header, *sites) + + def serialize(self): + from pylabrobot.serializer import serialize + + return { + **Device.serialize(self), + **Resource.serialize(self), + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + } diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/liconic/racks.py similarity index 100% rename from pylabrobot/storage/liconic/racks.py rename to pylabrobot/liconic/racks.py 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 50c01b189ad..02bf0363a33 100644 --- a/pylabrobot/liquid_handling/backends/__init__.py +++ b/pylabrobot/liquid_handling/backends/__init__.py @@ -1,9 +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_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 93181c0cf69..ccba26cfeb2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1,12176 +1,10 @@ -import asyncio -import datetime -import enum -import functools -import logging -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.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.backends.hamilton.planning import group_by_x_batch_by_xy -from pylabrobot.liquid_handling.channel_positioning import ( - MIN_SPACING_EDGE, - 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.standard import ( - Drop, - DropTipRack, - GripDirection, - 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, +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.hamilton_decks import ( - HamiltonCoreGrippers, - rails_for_x_coordinate, -) -from pylabrobot.resources.liquid import Liquid -from pylabrobot.resources.rotation import Rotation -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 = {36: "Hamilton will not run while the hood is open"} - 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 = { - 20: "No communication to EEPROM", - 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", - } - elif module_identifier == "R0": # iswap - 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", - } - - 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: # No error - 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 for an X drive (left or right). - - Combines byte 1 (xl/xr) and byte 2 (xn/xo) into a single object. - 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 - - -@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: DriveConfiguration = field(default_factory=DriveConfiguration) - """Right X drive configuration (xr + xo).""" - 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 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 - supports_clot_monitoring_clld: bool - stop_disc_type: StopDiscType - instrument_type: InstrumentType - head_type: HeadType - - -class STARBackend(HamiltonLiquidHandler, HamiltonHeaterShakerInterface): - """Interface for the Hamilton STARBackend.""" - - 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 - self.core_adjustment = Coordinate.zero() - self._unsafe = UnSafe(self) - - self._iswap_version: Optional[str] = None # loaded lazily - - self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" - - 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*. - - Uses max() of both channels' spacings for firmware safety (conservative). - For adjacent channels, ceiling-rounded to 0.1mm. - For non-adjacent channels, the sum of all intermediate adjacent-pair spacings. - """ - 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) - 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 - - async def get_iswap_version(self) -> str: - """Lazily load the iSWAP version. Use cached value if available.""" - if self._iswap_version is None: - self._iswap_version = await self.request_iswap_version() - return self._iswap_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"], - ) - - 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: - await self.move_core_96_to_safe_position() - - tip_presences = await self.request_tip_presence() - self._num_channels = len(tip_presences) - - async def set_up_pip(): - if (not initialized or any(tip_presences)) and not skip_pip: - await self.initialize_pip() - self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() - - 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) - ) - - 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() - - self._head96_information = Head96Information( - fw_version=fw_version, - supports_clot_monitoring_clld=bool(int(configuration_96head[0])), - stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", - instrument_type="legacy" if configuration_96head[2] == "0" else "FM-STAR", - head_type=head96_type, - ) - - 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 ============== - - # # # # 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 the minimum Y spacing for all channels in parallel. - - Each channel is addressed on its own module (P1, P2, ...), so the queries - can run concurrently. - - Returns: - A list of exact (unrounded) minimum Y spacings in mm, one per channel, - indexed by channel number. - """ - return list( - await asyncio.gather( - *( - self.channel_request_y_minimum_spacing(channel_idx=idx) - for idx 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 _move_to_traverse_height( - self, channels: Optional[List[int]] = None, traverse_height: Optional[float] = None - ): - """Move channels to a specified traverse height, if given, otherwise move to full Z safety. - - Args: - channels: Channels to move. If None, all channels are moved. - traverse_height: Absolute Z position in mm. If None, move to full Z safety. - """ - if traverse_height is None: - await self.move_all_channels_in_z_safety() - else: - if channels is None: - channels = list(range(self.num_channels)) - await self.position_channels_in_z_direction( - {channel: traverse_height for channel in channels} - ) - - async def _probe_liquid_heights_batch( - self, - containers: List[Container], - use_channels: List[int], - lld_mode: LLDMode = LLDMode.GAMMA, - search_speed: float = 10.0, - n_replicates: int = 1, - ) -> List[float]: - """Helper for probe_liquid_heights that performs a single batch of liquid level detection using a set of channels. - - Assumes channels are moved to the appropriate traverse height before calling, and does not move channels after completion. - """ - - tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - - detect_func: Callable[..., Any] - if lld_mode == self.LLDMode.GAMMA: - detect_func = self._move_z_drive_to_liquid_surface_using_clld - else: - detect_func = self._search_for_surface_using_plld - - # Compute Z search bounds for this batch - batch_lowest_immers = [ - container.get_absolute_location("c", "c", "cavity_bottom").z - + tip_len - - self.DEFAULT_TIP_FITTING_DEPTH - for container, tip_len in zip(containers, tip_lengths) - ] - batch_start_pos = [ - container.get_absolute_location("c", "c", "t").z - + tip_len - - self.DEFAULT_TIP_FITTING_DEPTH - + 5 - for container, tip_len in zip(containers, tip_lengths) - ] - - absolute_heights_measurements: Dict[int, List[Optional[float]]] = { - idx: [] for idx in range(len(use_channels)) - } - - # Run n_replicates detection loop for this batch - for _ in range(n_replicates): - errors = await asyncio.gather( - *[ - detect_func( - channel_idx=channel, - lowest_immers_pos=lip, - start_pos_search=sps, - channel_speed=search_speed, - ) - for channel, lip, sps in zip(use_channels, batch_lowest_immers, batch_start_pos) - ], - return_exceptions=True, - ) - - # Get heights for ALL channels, handling failures for channels with no liquid - current_absolute_liquid_heights = await self.request_pip_height_last_lld() - for idx, (channel_idx, error) in enumerate(zip(use_channels, errors)): - if isinstance(error, STARFirmwareError): - error_msg = str(error).lower() - if "no liquid level found" in error_msg or "no liquid was present" in error_msg: - height = None - msg = ( - f"Operation {idx} (channel {channel_idx}): No liquid detected. Could be because there is " - f"no liquid in container {containers[idx].name} or liquid level " - f"is too low." - ) - if lld_mode == self.LLDMode.GAMMA: - msg += " Consider using pressure-based LLD if liquid is believed to exist." - logger.warning(msg) - else: - raise error - elif isinstance(error, Exception): - raise error - else: - height = current_absolute_liquid_heights[channel_idx] - absolute_heights_measurements[idx].append(height) - - # Compute liquid heights relative to well bottom - relative_to_well: List[float] = [] - inconsistent_ops: List[str] = [] - - for idx, container in enumerate(containers): - measurements = absolute_heights_measurements[idx] - valid = [m for m in measurements if m is not None] - cavity_bottom = container.get_absolute_location("c", "c", "cavity_bottom").z - - 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_ops.append( - f"Operation {idx}: {len(valid)}/{len(measurements)} replicates detected liquid" - ) - - if inconsistent_ops: - raise RuntimeError( - "Inconsistent liquid detection across replicates. " - "This may indicate liquid levels near the detection limit:\n" + "\n".join(inconsistent_ops) - ) - - return relative_to_well - - def _get_maximum_minimum_spacing_between_channels(self, use_channels: List[int]) -> float: - """Get the maximum of the set of minimum spacing requirements between the channels being used""" - sorted_channels = sorted(use_channels) - max_channel_spacing = max( - self._min_spacing_between(hi, lo) for hi, lo in zip(sorted_channels[1:], sorted_channels[:-1]) - ) - return max_channel_spacing - - def _compute_channels_in_resource_locations( - self, - resources: Sequence[Resource], - use_channels: List[int], - offsets: Optional[List[Coordinate]], - ) -> List[Coordinate]: - """Compute absolute locations of resources with given offsets.""" - - # If no offset is provided but we can fit all channels inside a single resource, - # compute the offsets to make that happen using wide spacing. - if offsets is None: - if len(set(resources)) == 1 and len(use_channels) == len(set(use_channels)): - container_size_y = resources[0].get_absolute_size_y() - # For non-consecutive channels (e.g. [0,1,2,5,6,7]), we must account for - # phantom intermediate channels (3,4) that physically exist between them. - # Compute offsets for the full channel range (min to max), then pick only - # the offsets corresponding to the actual channels being used. - max_channel_spacing = self._get_maximum_minimum_spacing_between_channels(use_channels) - num_channels_in_span = max(use_channels) - min(use_channels) + 1 - min_required = MIN_SPACING_EDGE * 2 + (num_channels_in_span - 1) * max_channel_spacing - if container_size_y >= min_required: - all_offsets = get_wide_single_resource_liquid_op_offsets( - resource=resources[0], - num_channels=num_channels_in_span, - min_spacing=max_channel_spacing, - ) - min_ch = min(use_channels) - offsets = [all_offsets[ch - min_ch] for ch in use_channels] - # else: container too small to fit all channels — fall back to center offsets. - # Y sub-batching will serialize channels that can't coexist. - - offsets = offsets or [Coordinate.zero()] * len(resources) - - # Compute positions for all resources - resource_locations = [ - resource.get_location_wrt(self.deck, x="c", y="c", z="b") + offset - for resource, offset in zip(resources, offsets) - ] - - return resource_locations - - async def execute_batched( # TODO: any hamilton liquid handler - self, - func: Callable[[List[int]], Awaitable[None]], - resources: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - min_traverse_height_during_command: Optional[float] = None, - ): - if use_channels is None: - use_channels = list(range(len(resources))) - - # precompute locations and batches - locations = self._compute_channels_in_resource_locations( - resources, use_channels, resource_offsets - ) - x_batches = group_by_x_batch_by_xy( - locations=locations, - use_channels=use_channels, - min_spacing_between_channels=self._min_spacing_between, - ) - - # loop over batches. keep track of channels used in previous batch to ensure they are raised to traverse height before next batch - prev_channels: Optional[List[int]] = None - - try: - for x_value, x_batch in x_batches.items(): - if prev_channels is not None: - await self._move_to_traverse_height( - channels=prev_channels, traverse_height=min_traverse_height_during_command - ) - await self.move_channel_x(0, x_value) - - for y_batch in x_batch: - if prev_channels is not None: - await self._move_to_traverse_height( - channels=prev_channels, traverse_height=min_traverse_height_during_command - ) - await self.position_channels_in_y_direction( - {use_channels[idx]: locations[idx].y for idx in y_batch}, - ) - - await func(y_batch) - - prev_channels = [use_channels[idx] for idx in y_batch] - except Exception: - await self.move_all_channels_in_z_safety() - raise - except BaseException: - await self.move_all_channels_in_z_safety() - raise - - async def probe_liquid_heights( - self, - containers: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - lld_mode: LLDMode = LLDMode.GAMMA, - 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, - # 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. - - 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. Auto-calculated for single - containers with odd channel counts to avoid center dividers. Defaults to container centers. - lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. - Defaults to capacitive. - 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. None (default) uses full Z safety. - min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to - between batches (X groups and Y sub-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. - - Returns: - Mean of measured liquid heights for each container (mm from cavity bottom). - - Raises: - RuntimeError: If channels lack tips. - - Notes: - - All specified channels must have tips attached - - Containers at different X positions are probed in sequential groups (single X carriage) - - For single containers with no-go zones, Y-offsets are computed to avoid - obstructed regions (e.g. center dividers in troughs) - """ - - 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, - ) - - # Validate parameters. - if use_channels is None: - use_channels = list(range(len(containers))) - if len(use_channels) == 0: - raise ValueError("use_channels must not be empty.") - if not all(0 <= ch < self.num_channels for ch in use_channels): - raise ValueError( - f"All use_channels must be integers in range [0, {self.num_channels - 1}], " - f"got {use_channels}." - ) - - if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: - raise ValueError(f"LLDMode must be 1 (capacitive) or 2 (pressure-based), is {lld_mode}") - - if not len(containers) == len(use_channels): - raise ValueError( - "Length of containers and use_channels must match, " - f"got lengths {len(containers)}, {len(use_channels)}." - ) - - # Validate resource_offsets length (if provided) to avoid silent truncation in downstream zips. - if resource_offsets is not None and len(resource_offsets) != len(containers): - raise ValueError( - "Length of resource_offsets must match the length of containers and use_channels, " - f"got lengths {len(resource_offsets)} (resource_offsets) and " - f"{len(containers)} (containers/use_channels)." - ) - # Make sure we have tips on all channels and know their 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.") - - # Move channels to traverse height - await self._move_to_traverse_height( - channels=use_channels, traverse_height=min_traverse_height_at_beginning_of_command - ) - - result_by_operation: Dict[int, float] = {} - - async def func(batch: List[int]): - liquid_heights = await self._probe_liquid_heights_batch( - containers=[containers[idx] for idx in batch], - use_channels=[use_channels[idx] for idx in batch], - lld_mode=lld_mode, - search_speed=search_speed, - n_replicates=n_replicates, - ) - for idx, height in zip(batch, liquid_heights): - result_by_operation[idx] = height - - await self.execute_batched( - func=func, - resources=containers, - use_channels=use_channels, - resource_offsets=resource_offsets, - min_traverse_height_during_command=min_traverse_height_during_command, - ) - - await self._move_to_traverse_height( - channels=use_channels, - traverse_height=z_position_at_end_of_command, - ) - - return [result_by_operation[idx] for idx in range(len(containers))] - - async def probe_liquid_volumes( - self, - containers: List[Container], - use_channels: List[int], - resource_offsets: Optional[List[Coordinate]] = None, - lld_mode: LLDMode = LLDMode.GAMMA, - search_speed: float = 10.0, - n_replicates: int = 3, - move_to_z_safety_after: bool = True, - ) -> 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). - 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 - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. Defaults to capacitive. - search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. - n_replicates: Number of measurements per channel. Default 3. - move_to_z_safety_after: Whether to move channels to safe Z height after probing. Default True. - - 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 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, - move_to_z_safety_after=move_to_z_safety_after, - ) - - 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], - move_to_z_safety_after=False, - ) - - # 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}" - ) - - 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], - move_to_z_safety_after=False, - ) - - # 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 - ), - ) - - @_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 - 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, - ) - - @_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) - - 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), - ) - - 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)" - ) - 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 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). - - Returns the full instrument configuration matching the AK - (Set Instrument Configuration) [SFCO.0026] parameter set. - """ - - 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####", - ) - - def _parse_drive(byte1: int, byte2: int) -> DriveConfiguration: - 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)), - ) - - 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=_parse_drive(resp["xl"], resp["xn"]), - right_x_drive=_parse_drive(resp["xr"], resp["xo"]), - 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): - """Request maximal ranges of X drives""" - - return await self.send_command(module="C0", command="RU") - - async def request_present_wrap_size_of_installed_arms(self): - """Request present wrap size of installed arms""" - - return await self.send_command(module="C0", command="UA") - - 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 -------------- - - # TODO:(command:RY): Request Y-Positions of all pipetting channels - - 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) - - # TODO:(command:RZ): Request Z-Positions of all pipetting 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 a sequence of positional tokens. This method returns - those tokens without decoding them, but the following indices are currently - understood: - - - 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: reservable positions (positions 4..10) - - 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") - - # -------------- 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 (mm per increment) - _head96_z_drive_mm_per_increment = 0.005 - _head96_y_drive_mm_per_increment = 0.015625 - _head96_dispensing_drive_mm_per_increment = 0.001025641026 - _head96_dispensing_drive_uL_per_increment = 0.019340933 - _head96_squeezer_drive_mm_per_increment = 0.0002086672009 - - # 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) - - # 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) - - # 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) - - # 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): - """Move 96-Head to Z safety coordinate, i.e. z=342.5 mm.""" - return await self.send_command(module="C0", command="EV") - - @_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): - """Move the 96-head to a specified X-axis coordinate. - - Note: Unlike head96_move_y and head96_move_z, the X-axis movement does not have - dedicated speed/acceleration parameters - it uses the EM command which moves - all axes together. - - Args: - x: Target X coordinate in mm. Valid range: [-271.0, 974.0] - - Returns: - Response from the hardware command. - - Raises: - RuntimeError: If 96-head is not installed. - """ - current_pos = await self.head96_request_position() - return await self.head96_move_to_coordinate( - Coordinate(x, current_pos.y, current_pos.z), - minimum_height_at_beginning_of_a_command=current_pos.z - 10, - ) - - @_requires_head96 - async def head96_move_y( - self, - y: float, - speed: float = 300.0, - acceleration: float = 300.0, - current_protection_limiter: int = 15, - ): - """Move the 96-head to a specified Y-axis coordinate. - - Args: - y: Target Y coordinate in mm. Valid range: [93.75, 562.5] - speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]. Default: 300.0 - acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]. Default: 300.0 - 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: - Maximum speed varies by firmware version: - - Pre-2021: 390.625 mm/sec (25,000 increments) - - 2021+: 625.0 mm/sec (40,000 increments) - The exact firmware version introducing this change is undocumented. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - - fw_version = self._head96_information.fw_version - - # Determine speed limit based on firmware version - # Pre-2021 firmware appears to have lower speed capability or safety limits - # TODO: Verify exact firmware version and investigate the reason for this change - y_speed_upper_limit = 390.625 if fw_version.year <= 2021 else 625.0 # mm/sec - - # Validate parameters before hardware communication - assert 93.75 <= y <= 562.5, "y must be between 93.75 and 562.5 mm" - assert 0.78125 <= speed <= y_speed_upper_limit, ( - f"speed must be between 0.78125 and {y_speed_upper_limit} mm/sec for firmware version {fw_version}. " - f"Your firmware version: {self._head96_information.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 78.125 <= acceleration <= 781.25, ( - "acceleration must be between 78.125 and 781.25 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) - - resp = 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}", - ) - - return resp - - @_requires_head96 - async def head96_move_z( - self, - z: float, - speed: float = 80.0, - acceleration: float = 300.0, - current_protection_limiter: int = 15, - ): - """Move the 96-head to a specified Z-axis coordinate. - - Args: - z: Target Z coordinate in mm. Valid range: [180.5, 342.5] - speed: Movement speed in mm/sec. Valid range: [0.25, 100.0]. Default: 80.0 - acceleration: Movement acceleration in mm/sec^2. Valid range: [25.0, 500.0]. Default: 300.0 - 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: - 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" - ) - - fw_version = self._head96_information.fw_version - - # Validate parameters before hardware communication - assert 180.5 <= z <= 342.5, "z must be between 180.5 and 342.5 mm" - assert 0.25 <= speed <= 100.0, "speed must be between 0.25 and 100.0 mm/sec" - assert 25.0 <= acceleration <= 500.0, "acceleration must be between 25.0 and 500.0 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 - ) - - resp = 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}", - ) - - return resp - - # -------------- 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 -------------- - - # # # 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", - 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", - 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_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) - - 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): - """Move iSWAP X to absolute position""" - loc = await self.request_iswap_position() - await self.move_iswap_x_relative( - step_size=x_position - loc.x, - allow_splitting=True, - ) - - async def move_iswap_y(self, y_position: float): - """Move iSWAP Y to absolute position""" - loc = await self.request_iswap_position() - await self.move_iswap_y_relative( - step_size=y_position - loc.y, - allow_splitting=True, - ) - - async def move_iswap_z(self, z_position: float): - """Move iSWAP Z to absolute position""" - loc = await self.request_iswap_position() - await self.move_iswap_z_relative( - step_size=z_position - loc.z, - allow_splitting=True, - ) - - async def open_not_initialized_gripper(self): - 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 - - assert 0 <= open_position <= 999.9, "open_position must be between 0 and 999.9" - - 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 = 0, - plate_width_tolerance: float = 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 ) - plate_width_tolerance: Plate width tolerance [mm]. Must be between 0 and 9.9. Default 2.0. - """ - - assert 0 <= grip_strength <= 9, "grip_strength must be between 0 and 9" - assert 0 <= plate_width <= 999.9, "plate_width must be between 0 and 999.9" - assert 0 <= plate_width_tolerance <= 9.9, "plate_width_tolerance must be between 0 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 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"]) - - async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": - """ - Request the iSWAP rotation drive orientation. - This is the orientation of the iSWAP rotation drive (relative to the machine). - - Uses empirically determined increment values: - FRONT: -25 ± 50 - RIGHT: +29068 ± 50 - LEFT: -29116 ± 50 - - Returns: - RotationDriveOrientation: The interpreted rotation orientation (LEFT, FRONT, RIGHT). - """ - # Map motor increments to rotation orientations (constant lookup table). - rotation_orientation_to_motor_increment_dict = { - STARBackend.RotationDriveOrientation.FRONT: range(-75, 26), - STARBackend.RotationDriveOrientation.RIGHT: range(29018, 29119), - STARBackend.RotationDriveOrientation.LEFT: range(-29166, -29065), - STARBackend.RotationDriveOrientation.PARKED_RIGHT: range(29450, 29550), - # TODO: add range for STAR(let)s with "PARKED_LEFT" setting - } - - motor_position_increments = await self.request_iswap_rotation_drive_position_increments() - - for orientation, increment_range in rotation_orientation_to_motor_increment_dict.items(): - if motor_position_increments in increment_range: - return orientation - - raise ValueError( - f"Unknown rotation orientation: {motor_position_increments}. " - f"Expected one of {list(rotation_orientation_to_motor_increment_dict.values())}." - ) - - 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 request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": - """ - Request the iSWAP wrist drive orientation. - This is the orientation of the iSWAP wrist drive (always in relation to the iSWAP arm/rotation drive). - - e.g.: - 1) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the front) - - 2) iSWAP RotationDriveOrientation.LEFT (i.e. pointing to the left of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the left) - - 3) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.RIGHT (i.e. wrist is pointing to the left !) - - The relative wrist orientation is reported as a motor position increment by the STAR firmware. This value is mapped to a `WristDriveOrientation` enum member. - - Returns: - WristDriveOrientation: The interpreted wrist orientation (e.g., RIGHT, STRAIGHT, LEFT, REVERSE). - """ - - # Map motor increments to wrist orientations (constant lookup table). - wrist_orientation_to_motor_increment_dict = { - STARBackend.WristDriveOrientation.RIGHT: range(-26_627, -26_527), - STARBackend.WristDriveOrientation.STRAIGHT: range(-8_804, -8_704), - STARBackend.WristDriveOrientation.LEFT: range(9_051, 9_151), - STARBackend.WristDriveOrientation.REVERSE: range(26_802, 26_902), - } - - motor_position_increments = await self.request_iswap_wrist_drive_position_increments() - - for orientation, increment_range in wrist_orientation_to_motor_increment_dict.items(): - if motor_position_increments in increment_range: - return orientation - - raise ValueError( - f"Unknown wrist orientation: {motor_position_increments}. " - f"Expected one of {list(wrist_orientation_to_motor_increment_dict)}." - ) - - 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_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 request_iswap_position(self) -> Coordinate: - """Request iSWAP position ( grip center ) - - Returns: - xs: Hotel center in X direction [1mm] - xd: X direction 0 = positive 1 = negative - yj: Gripper center in Y direction [1mm] - yd: Y direction 0 = positive 1 = negative - zj: Gripper Z height (gripping 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 iswap_rotation_drive_request_y(self) -> float: - """Request iSWAP rotation drive Y position (center) in mm. This is equivalent to the y location of the iSWAP module.""" - 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.y_drive_increment_to_mm(iswap_y_pos), 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"]) - - # -------------- 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.046302082 - 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 \n0 and" - + f"{STARBackend.y_drive_increment_to_mm(13_714) + 9} mm, is {max_y_search_pos_increments} 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 - - 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 - - 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) - - class RotationDriveOrientation(enum.Enum): - LEFT = 1 - FRONT = 2 - RIGHT = 3 - PARKED_RIGHT = None - - async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): - 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}") - - class WristDriveOrientation(enum.Enum): - RIGHT = 1 - STRAIGHT = 2 - LEFT = 3 - REVERSE = 4 - - async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): - return await self.send_command( - module="R0", - command="TP", - auto_id=False, - tp=orientation.value, - ) - - @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 - # less than their minimum spacing apart (typically 9 mm). (When you set channels using - # position_channels_in_y_direction, it will raise an error.) The minimum y is 6mm, - # so we fix that first (in case that value is misreported). Then, we traverse the - # list in reverse and enforce pairwise 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}=9mm. We start with the channel closest to `back_channel`, and make sure the - # channel behind it is at least 9mm, updating if needed. Iterating from the front (closest - # to `back_channel`) to the back (channel 0), all channels are put at the correct location. - # This order matters because the channel in front of any channel may have been moved in the - # previous iteration. - # Note that if a channel is already spaced at >=9mm, it is not moved. - for channel_idx in range(back_channel, 0, -1): - spacing = self._min_spacing_between(channel_idx - 1, channel_idx) - if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < spacing: - channel_locations[channel_idx - 1] = channel_locations[channel_idx] + spacing - - # Similarly for the channels to the front of `front_channel`, make sure they are all - # spaced >= channel_minimum_y_spacing (usually 9mm) apart. This time, we iterate from - # back (closest to `front_channel`) to the front (lh.backend.num_channels - 1), and - # put each channel >= channel_minimum_y_spacing before the one behind it. - for channel_idx in range(front_channel, self.num_channels - 1): - spacing = self._min_spacing_between(channel_idx, channel_idx + 1) - if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < spacing: - channel_locations[channel_idx + 1] = channel_locations[channel_idx] - spacing - - # Quick checks before movement. - if channel_locations[0] > 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=self._get_maximum_minimum_spacing_between_channels(piercing_channels), - ) - 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 fc642de8b33..d58114a756d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -1,318 +1,10 @@ -import copy -import datetime import warnings -from contextlib import asynccontextmanager -from typing import Dict, List, Literal, Optional, Union -from pylabrobot.liquid_handling.backends import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import ( - DriveConfiguration, - ExtendedConfiguration, - Head96Information, - MachineConfiguration, - STARBackend, +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.well import Well -_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, -) - - -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, - 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`. - 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 - - 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: - self._head96_information = Head96Information( - fw_version=datetime.date(2023, 1, 1), - supports_clot_monitoring_clld=False, - stop_disc_type="core_ii", - instrument_type="FM-STAR", - head_type="96 head II", - ) - else: - self._head96_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]: - print(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]: - print(command) - return None - - # # # # # # # # STAR configuration # # # # # # # # - - async def request_machine_configuration(self) -> MachineConfiguration: - return self._machine_configuration - - async def request_extended_configuration(self) -> ExtendedConfiguration: - assert self._extended_conf is not None - return self._extended_conf - - # # # # # # # # 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 move_channel_y(self, channel: int, y: float): - print(f"moving channel {channel} to y: {y}") - - async def move_channel_x(self, channel: int, x: float): - print(f"moving channel {channel} to x: {x}") - - async def move_all_channels_in_z_safety(self): - print("moving all channels to z safety") - - async def position_channels_in_z_direction(self, zs: Dict[int, float]): - print(f"positioning channels in z: {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, - ): - print( - f"stepping off foil | wells: {wells} | front channel: {front_channel} | " - f"back channel: {back_channel} | move inwards: {move_inwards} | move height: {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, - ): - print( - f"piercing foil | wells: {wells} | piercing channels: {piercing_channels} | " - f"hold down channels: {hold_down_channels} | move inwards: {move_inwards} | " - f"spread: {spread} | one by one: {one_by_one} | distance from bottom: {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) - - # # # # # # # # 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): - print("moving iswap x to", x_position) - - async def move_iswap_y(self, y_position: float): - print("moving iswap y to", y_position) - - async def move_iswap_z(self, z_position: float): - print("moving iswap z to", 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") - print(" | ".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): - print("positioning channels in y:", ys, "make_space:", make_space) - - async def request_pip_height_last_lld(self): - return list(range(12)) +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/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py deleted file mode 100644 index 7018e22590b..00000000000 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ /dev/null @@ -1,2312 +0,0 @@ -"""Hamilton Nimbus backend implementation. - -This module provides the NimbusBackend class for controlling Hamilton Nimbus -instruments via TCP communication using the Hamilton protocol. -""" - -from __future__ import annotations - -import enum -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 ( - HamiltonIntrospection, -) -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.backends.hamilton.tcp_backend import HamiltonTCPBackend -from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - PipettingOp, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, -) -from pylabrobot.resources import Tip -from pylabrobot.resources.container import Container -from pylabrobot.resources.hamilton import HamiltonTip, TipSize -from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck -from pylabrobot.resources.trash import Trash - -logger = logging.getLogger(__name__) - - -T = TypeVar("T") - - -# ============================================================================ -# TIP TYPE ENUM -# ============================================================================ - - -class NimbusTipType(enum.IntEnum): - """Hamilton Nimbus tip type enumeration. - - Maps tip type names to their integer values used in Hamilton protocol commands. - """ - - STANDARD_300UL = 0 # "300ul Standard Volume Tip" - STANDARD_300UL_FILTER = 1 # "300ul Standard Volume Tip with filter" - LOW_VOLUME_10UL = 2 # "10ul Low Volume Tip" - LOW_VOLUME_10UL_FILTER = 3 # "10ul Low Volume Tip with filter" - HIGH_VOLUME_1000UL = 4 # "1000ul High Volume Tip" - HIGH_VOLUME_1000UL_FILTER = 5 # "1000ul High Volume Tip with filter" - TIP_50UL = 22 # "50ul Tip" - TIP_50UL_FILTER = 23 # "50ul Tip with filter" - SLIM_CORE_300UL = 36 # "SLIM CO-RE Tip 300ul" - - -def _get_tip_type_from_tip(tip: Tip) -> int: - """Map Tip object characteristics to Hamilton tip type integer. - - Args: - tip: Tip object with volume and filter information. Must be a HamiltonTip. - - Returns: - Hamilton tip type integer value. - - Raises: - ValueError: If tip characteristics don't match any known tip type. - """ - - if not isinstance(tip, HamiltonTip): - raise ValueError("Tip must be a HamiltonTip to determine tip type.") - - if tip.tip_size == TipSize.LOW_VOLUME: # 10ul tip - return NimbusTipType.LOW_VOLUME_10UL_FILTER if tip.has_filter else NimbusTipType.LOW_VOLUME_10UL - - if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 60: # 50ul tip - return NimbusTipType.TIP_50UL_FILTER if tip.has_filter else NimbusTipType.TIP_50UL - - if tip.tip_size == TipSize.STANDARD_VOLUME: # 300ul tip - return NimbusTipType.STANDARD_300UL_FILTER if tip.has_filter else NimbusTipType.STANDARD_300UL - - if tip.tip_size == TipSize.HIGH_VOLUME: # 1000ul tip - return ( - NimbusTipType.HIGH_VOLUME_1000UL_FILTER - if tip.has_filter - else NimbusTipType.HIGH_VOLUME_1000UL - ) - - raise ValueError( - f"Cannot determine tip type for tip with volume {tip.maximal_volume}uL " - f"and filter={tip.has_filter}. No matching Hamilton tip type found." - ) - - -def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: - """Get default flow rate based on tip type. - - Defaults from Hamilton Nimbus: - - 1000 ul tip: 250 asp / 400 disp - - 300 and 50 ul tip: 100 asp / 180 disp - - 10 ul tip: 100 asp / 75 disp - - Args: - tip: Tip object to determine default flow rate for. - is_aspirate: True for aspirate, False for dispense. - - Returns: - Default flow rate in uL/s. - """ - tip_type = _get_tip_type_from_tip(tip) - - if tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): - return 250.0 if is_aspirate else 400.0 - - if tip_type in (NimbusTipType.LOW_VOLUME_10UL, NimbusTipType.LOW_VOLUME_10UL_FILTER): - return 100.0 if is_aspirate else 75.0 - - # 50 and 300 ul tips - return 100.0 if is_aspirate else 180.0 - - -# ============================================================================ -# COMMAND CLASSES -# ============================================================================ - - -class LockDoor(HamiltonCommand): - """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 1 - - -class UnlockDoor(HamiltonCommand): - """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 2 - - -class IsDoorLocked(HamiltonCommand): - """Check if door is locked (DoorLock at 1:1:268, interface_id=1, command_id=3).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 3 - action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsDoorLocked response.""" - parser = HoiParamsParser(data) - _, locked = parser.parse_next() - return {"locked": bool(locked)} - - -class PreInitializeSmart(HamiltonCommand): - """Pre-initialize smart command (Pipette at 1:1:257, interface_id=1, command_id=32).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 32 - - -class InitializeSmartRoll(HamiltonCommand): - """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 29 - - def __init__( - self, - dest: Address, - x_positions: List[int], - y_positions: List[int], - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize InitializeSmartRoll command. - - Args: - dest: Destination address (NimbusCore) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distances in 0.01mm units - """ - super().__init__(dest) - self.x_positions = x_positions - self.y_positions = y_positions - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class IsInitialized(HamiltonCommand): - """Check if instrument is initialized (NimbusCore at 1:1:48896, interface_id=1, command_id=14).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 14 - action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsInitialized response.""" - parser = HoiParamsParser(data) - _, initialized = parser.parse_next() - return {"initialized": bool(initialized)} - - -class IsTipPresent(HamiltonCommand): - """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 16 - action_code = 0 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsTipPresent response - returns List[i16].""" - parser = HoiParamsParser(data) - # Parse array of i16 values representing tip presence per channel - _, tip_presence = parser.parse_next() - return {"tip_present": tip_presence} - - -class GetChannelConfiguration_1(HamiltonCommand): - """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 15 - action_code = 0 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration_1 response. - - Returns: (channels: u16, channel_types: List[i16]) - """ - parser = HoiParamsParser(data) - _, channels = parser.parse_next() - _, channel_types = parser.parse_next() - return {"channels": channels, "channel_types": channel_types} - - -class SetChannelConfiguration(HamiltonCommand): - """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 67 - - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - enables: List[bool], - ): - """Initialize SetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [1, 3, 4]) - 1: Tip Recognition, 2: Aspirate and clot monitoring pLLD, - 3: Aspirate monitoring with cLLD, 4: Clot monitoring with cLLD - enables: List of enable flags (e.g., [True, False, False, False]) - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - self.enables = enables - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes).bool_array(self.enables) - - -class Park(HamiltonCommand): - """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 3 - - -class PickupTips(HamiltonCommand): - """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 4 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_pick_up_process: List[int], - end_tip_pick_up_process: List[int], - tip_types: List[int], - ): - """Initialize PickupTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_pick_up_process: Z start positions in 0.01mm units - end_tip_pick_up_process: Z stop positions in 0.01mm units - tip_types: Tip type integers for each channel - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_pick_up_process = begin_tip_pick_up_process - self.end_tip_pick_up_process = end_tip_pick_up_process - self.tip_types = tip_types - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_pick_up_process) - .i32_array(self.end_tip_pick_up_process) - .u16_array(self.tip_types) - ) - - -class DropTips(HamiltonCommand): - """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 5 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - default_waste: bool, - ): - """Initialize DropTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - default_waste: If True, drop to default waste (positions may be ignored) - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.default_waste = default_waste - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .bool_value(self.default_waste) - ) - - -class DropTipsRoll(HamiltonCommand): - """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 82 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize DropTipsRoll command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distance for each channel in 0.01mm units - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class EnableADC(HamiltonCommand): - """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 43 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize EnableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) - - -class DisableADC(HamiltonCommand): - """Disable ADC command (Pipette at 1:1:257, interface_id=1, command_id=44).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 44 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize DisableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) - - -class GetChannelConfiguration(HamiltonCommand): - """Get channel configuration command (Pipette at 1:1:257, interface_id=1, command_id=66).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 66 - action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - ): - """Initialize GetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [2] for "Aspirate monitoring with cLLD") - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration response. - - Returns: { enabled: List[bool] } - """ - parser = HoiParamsParser(data) - _, enabled = parser.parse_next() - return {"enabled": enabled} - - -class Aspirate(HamiltonCommand): - """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 6 - - def __init__( - self, - dest: Address, - aspirate_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - clot_detection_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - blow_out_air_volume: List[int], - pre_wetting_volume: List[int], - aspirate_volume: List[int], - transport_air_volume: List[int], - aspiration_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - dp_lld_sensitivity: List[int], - lld_height_difference: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Aspirate command. - - Args: - dest: Destination address (Pipette) - aspirate_type: Aspirate type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - clot_detection_height: Clot detection height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - pre_wetting_volume: Pre-wetting volume for each channel in 0.1uL units - aspirate_volume: Aspirate volume for each channel in 0.1uL units - transport_air_volume: Transport air volume for each channel in 0.1uL units - aspiration_speed: Aspirate speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - dp_lld_sensitivity: DP LLD sensitivity for each channel (List[i16]) - lld_height_difference: LLD height difference for each channel in 0.01mm units - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.aspirate_type = aspirate_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.clot_detection_height = clot_detection_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.blow_out_air_volume = blow_out_air_volume - self.pre_wetting_volume = pre_wetting_volume - self.aspirate_volume = aspirate_volume - self.transport_air_volume = transport_air_volume - self.aspiration_speed = aspiration_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.dp_lld_sensitivity = dp_lld_sensitivity - self.lld_height_difference = lld_height_difference - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.aspirate_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32_array(self.clot_detection_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.blow_out_air_volume) - .u32_array(self.pre_wetting_volume) - .u32_array(self.aspirate_volume) - .u32_array(self.transport_air_volume) - .u32_array(self.aspiration_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .i16_array(self.dp_lld_sensitivity) - .i32_array(self.lld_height_difference) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) - - -class Dispense(HamiltonCommand): - """Dispense command (Pipette at 1:1:257, interface_id=1, command_id=7).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 7 - - def __init__( - self, - dest: Address, - dispense_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - transport_air_volume: List[int], - dispense_volume: List[int], - stop_back_volume: List[int], - blow_out_air_volume: List[int], - dispense_speed: List[int], - cut_off_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - side_touch_off_distance: int, - dispense_offset: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Dispense command. - - Args: - dest: Destination address (Pipette) - dispense_type: Dispense type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - transport_air_volume: Transport air volume for each channel in 0.1uL units - dispense_volume: Dispense volume for each channel in 0.1uL units - stop_back_volume: Stop back volume for each channel in 0.1uL units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - dispense_speed: Dispense speed for each channel in 0.1uL/s units - cut_off_speed: Cut off speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - side_touch_off_distance: Side touch off distance in 0.01mm units - dispense_offset: Dispense offset for each channel in 0.01mm units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.dispense_type = dispense_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.transport_air_volume = transport_air_volume - self.dispense_volume = dispense_volume - self.stop_back_volume = stop_back_volume - self.blow_out_air_volume = blow_out_air_volume - self.dispense_speed = dispense_speed - self.cut_off_speed = cut_off_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.side_touch_off_distance = side_touch_off_distance - self.dispense_offset = dispense_offset - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.dispense_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.transport_air_volume) - .u32_array(self.dispense_volume) - .u32_array(self.stop_back_volume) - .u32_array(self.blow_out_air_volume) - .u32_array(self.dispense_speed) - .u32_array(self.cut_off_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32(self.side_touch_off_distance) - .i32_array(self.dispense_offset) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) - - -# ============================================================================ -# MAIN BACKEND CLASS -# ============================================================================ - - -class NimbusBackend(HamiltonTCPBackend): - """Backend for Hamilton Nimbus liquid handling instruments. - - This backend uses TCP communication with the Hamilton protocol to control - Nimbus instruments. It inherits from both TCPBackend (for communication) - and LiquidHandlerBackend (for liquid handling interface). - - Attributes: - _door_lock_available: Whether door lock is available on this instrument. - """ - - def __init__( - self, - host: str, - port: int = 2000, - read_timeout: float = 30.0, - write_timeout: float = 30.0, - auto_reconnect: bool = True, - max_reconnect_attempts: int = 3, - ): - """Initialize Nimbus backend. - - Args: - host: Hamilton instrument IP address - port: Hamilton instrument port (default: 2000) - 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__( - host=host, - port=port, - read_timeout=read_timeout, - write_timeout=write_timeout, - auto_reconnect=auto_reconnect, - max_reconnect_attempts=max_reconnect_attempts, - ) - - self._num_channels: Optional[int] = None - self._pipette_address: Optional[Address] = None - self._door_lock_address: Optional[Address] = None - self._nimbus_core_address: Optional[Address] = None - self._is_initialized: Optional[bool] = None - self._channel_configurations: Optional[Dict[int, Dict[int, bool]]] = None - - self._channel_traversal_height: float = 146.0 # Default traversal height in mm - - async def setup(self, unlock_door: bool = False, force_initialize: bool = False): - """Set up the Nimbus backend. - - This method: - 1. Establishes TCP connection and performs protocol initialization - 2. Discovers instrument objects - 3. Queries channel configuration to get num_channels - 4. Queries tip presence - 5. Queries initialization status - 6. Locks door if available - 7. Conditionally initializes NimbusCore with InitializeSmartRoll (only if not initialized) - 8. Optionally unlocks door after initialization - - Args: - unlock_door: If True, unlock door after initialization (default: False) - force_initialize: If True, force initialization even if already initialized - """ - # Call parent setup (TCP connection, Protocol 7 init, Protocol 3 registration) - await super().setup() - - # Discover instrument objects - await self._discover_instrument_objects() - - # Ensure required objects are discovered - if self._pipette_address is None: - raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") - if self._nimbus_core_address is None: - raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") - - # Query channel configuration to get num_channels (use discovered address only) - try: - config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) - assert config is not None, "GetChannelConfiguration_1 command returned None" - self._num_channels = config["channels"] - logger.info(f"Channel configuration: {config['channels']} channels") - except Exception as e: - logger.error(f"Failed to query channel configuration: {e}") - raise - - # Query tip presence (use discovered address only) - try: - tip_present = await self.request_tip_presence() - logger.info(f"Tip presence: {tip_present}") - except Exception as e: - logger.warning(f"Failed to query tip presence: {e}") - - # Query initialization status (use discovered address only) - try: - init_status = await self.send_command(IsInitialized(self._nimbus_core_address)) - assert init_status is not None, "IsInitialized command returned None" - self._is_initialized = init_status.get("initialized", False) - logger.info(f"Instrument initialized: {self._is_initialized}") - except Exception as e: - logger.error(f"Failed to query initialization status: {e}") - raise - - # Lock door if available (optional - no error if not found) - # This happens before initialization - if self._door_lock_address is not None: - try: - if not await self.is_door_locked(): - await self.lock_door() - else: - logger.info("Door already locked") - except RuntimeError: - # Door lock not available or not set up - this is okay - logger.warning("Door lock operations skipped (not available or not set up)") - except Exception as e: - logger.warning(f"Failed to lock door: {e}") - - # Conditional initialization - only if not already initialized - if not self._is_initialized or force_initialize: - # Set channel configuration for each channel (required before InitializeSmartRoll) - try: - # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel - # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] - for channel in range(1, self.num_channels + 1): - await self.send_command( - SetChannelConfiguration( - dest=self._pipette_address, - channel=channel, - indexes=[1, 3, 4], - enables=[True, False, False, False], - ) - ) - logger.info(f"Channel configuration set for {self.num_channels} channels") - except Exception as e: - logger.error(f"Failed to set channel configuration: {e}") - raise - - # Initialize NimbusCore with InitializeSmartRoll using waste positions - try: - # Build waste position parameters using helper method - # Use all channels (0 to num_channels-1) for setup - all_channels = list(range(self.num_channels)) - - # Use same logic as DropTipsRoll: z_start = waste_z + 4.0mm, z_stop = waste_z, z_position_at_end = minimum_traverse_height_at_beginning_of_a_command - ( - x_positions_full, - y_positions_full, - begin_tip_deposit_process_full, - end_tip_deposit_process_full, - z_position_at_end_of_a_command_full, - roll_distances_full, - ) = self._build_waste_position_params( - use_channels=all_channels, - z_position_at_end_of_a_command=None, # Will default to minimum_traverse_height_at_beginning_of_a_command - roll_distance=None, # Will default to 9.0mm - ) - - await self.send_command( - InitializeSmartRoll( - dest=self._nimbus_core_address, - x_positions=x_positions_full, - y_positions=y_positions_full, - begin_tip_deposit_process=begin_tip_deposit_process_full, - end_tip_deposit_process=end_tip_deposit_process_full, - z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, - roll_distances=roll_distances_full, - ) - ) - logger.info("NimbusCore initialized with InitializeSmartRoll successfully") - self._is_initialized = True - except Exception as e: - logger.error(f"Failed to initialize NimbusCore with InitializeSmartRoll: {e}") - raise - else: - logger.info("Instrument already initialized, skipping initialization") - - # Unlock door if requested (optional - no error if not found) - if unlock_door and self._door_lock_address is not None: - try: - await self.unlock_door() - except RuntimeError: - # Door lock not available or not set up - this is okay - logger.warning("Door unlock requested but not available or not set up") - except Exception as e: - logger.warning(f"Failed to unlock door: {e}") - - async def _discover_instrument_objects(self): - """Discover instrument-specific objects using introspection.""" - introspection = HamiltonIntrospection(self) - - # Get root objects (already discovered in setup) - root_objects = self._discovered_objects.get("root", []) - if not root_objects: - logger.warning("No root objects discovered") - return - - # Use first root object as NimbusCore - nimbus_core_addr = root_objects[0] - self._nimbus_core_address = nimbus_core_addr - - try: - # Get NimbusCore object info - core_info = await introspection.get_object(nimbus_core_addr) - - # Discover subobjects to find Pipette and DoorLock - for i in range(core_info.subobject_count): - try: - sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) - sub_info = await introspection.get_object(sub_addr) - - # Check if this is the Pipette by interface name - if sub_info.name == "Pipette": - self._pipette_address = sub_addr - logger.info(f"Found Pipette at {sub_addr}") - - # Check if this is the DoorLock by interface name - if sub_info.name == "DoorLock": - self._door_lock_address = sub_addr - logger.info(f"Found DoorLock at {sub_addr}") - - except Exception as e: - logger.debug(f"Failed to get subobject {i}: {e}") - - except Exception as e: - logger.warning(f"Failed to discover instrument objects: {e}") - - # If door lock not found via introspection, it's not available - if self._door_lock_address is None: - logger.info("DoorLock not available on this instrument") - - def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: - """Returns a full-length list of size `num_channels` where positions in `channels` - are filled from `values` in order; all others are `default`. Similar to one-hot encoding.""" - if len(values) != len(use_channels): - raise ValueError( - f"values and channels must have same length (got {len(values)} vs {len(use_channels)})" - ) - - out = [default] * self.num_channels - for ch, v in zip(use_channels, values): - out[ch] = v - return out - - @property - def num_channels(self) -> int: - """The number of channels that the robot has.""" - if self._num_channels is None: - raise RuntimeError("num_channels not set. Call setup() first to query from instrument.") - return self._num_channels - - def set_minimum_channel_traversal_height(self, traversal_height: float): - """Set the minimum traversal height for the channels. - - 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 - for all commands, unless they are explicitly set in the command call. - """ - - if not 0 < traversal_height < 146: - raise ValueError(f"Traversal height must be between 0 and 146 mm (got {traversal_height})") - - self._channel_traversal_height = traversal_height - - async def park(self): - """Park the instrument. - - Raises: - RuntimeError: If NimbusCore address was not discovered during setup. - """ - if self._nimbus_core_address is None: - raise RuntimeError("NimbusCore address not discovered. Call setup() first.") - - try: - await self.send_command(Park(self._nimbus_core_address)) - logger.info("Instrument parked successfully") - except Exception as e: - logger.error(f"Failed to park instrument: {e}") - raise - - async def is_door_locked(self) -> bool: - """Check if the door is locked. - - Returns: - True if door is locked, False if unlocked. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) - - try: - status = await self.send_command(IsDoorLocked(self._door_lock_address)) - assert status is not None, "IsDoorLocked command returned None" - return bool(status["locked"]) - except Exception as e: - logger.error(f"Failed to check door lock status: {e}") - raise - - async def lock_door(self) -> None: - """Lock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) - - try: - await self.send_command(LockDoor(self._door_lock_address)) - logger.info("Door locked successfully") - except Exception as e: - logger.error(f"Failed to lock door: {e}") - raise - - async def unlock_door(self) -> None: - """Unlock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) - - try: - await self.send_command(UnlockDoor(self._door_lock_address)) - logger.info("Door unlocked successfully") - except Exception as e: - logger.error(f"Failed to unlock door: {e}") - raise - - async def stop(self): - """Stop the backend and close connection.""" - await HamiltonTCPBackend.stop(self) - - async def request_tip_presence(self) -> List[Optional[bool]]: - """Request tip presence on each channel. - - Returns: - A list of length `num_channels` where each element is `True` if a tip is mounted, - `False` if not, or `None` if unknown. - """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - tip_status = await self.send_command(IsTipPresent(self._pipette_address)) - assert tip_status is not None, "IsTipPresent command returned None" - tip_present = tip_status.get("tip_present", []) - return [bool(v) for v in tip_present] - - def _build_waste_position_params( - self, - use_channels: List[int], - z_position_at_end_of_a_command: Optional[float] = None, - roll_distance: Optional[float] = None, - ) -> Tuple[List[int], List[int], List[int], List[int], List[int], List[int]]: - """Build waste position parameters for InitializeSmartRoll or DropTipsRoll. - - Args: - use_channels: List of channel indices to use - z_position_at_end_of_a_command: Z final position in mm (absolute, optional, defaults to minimum_traverse_height_at_beginning_of_a_command) - roll_distance: Roll distance in mm (optional, defaults to 9.0 mm) - - Returns: - x_positions, y_positions, begin_tip_deposit_process_full, end_tip_deposit_process_full, z_position_at_end_of_a_command, roll_distances (all in 0.01mm units as lists matching num_channels) - - Raises: - RuntimeError: If deck is not set or waste position not found - """ - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - # Extract coordinates for each channel - x_positions_mm: List[float] = [] - y_positions_mm: List[float] = [] - z_positions_mm: List[float] = [] - - for channel_idx in use_channels: - # Get waste position from deck based on channel index - # Use waste_type attribute from deck to construct waste position name - if not hasattr(self.deck, "waste_type") or self.deck.waste_type is None: - raise RuntimeError( - f"Deck does not have waste_type attribute or waste_type is None. " - f"Cannot determine waste position name for channel {channel_idx}." - ) - waste_pos_name = f"{self.deck.waste_type}_{channel_idx + 1}" - try: - waste_pos = self.deck.get_resource(waste_pos_name) - abs_location = waste_pos.get_location_wrt(self.deck) - except Exception as e: - raise RuntimeError( - f"Failed to get waste position {waste_pos_name} for channel {channel_idx}: {e}" - ) - - # Convert to Hamilton coordinates (returns in mm) - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - - x_positions_mm.append(hamilton_coord.x) - y_positions_mm.append(hamilton_coord.y) - z_positions_mm.append(hamilton_coord.z) - - # Convert positions to 0.01mm units (multiply by 100) - x_positions = [round(x * 100) for x in x_positions_mm] - y_positions = [round(y * 100) for y in y_positions_mm] - - # Calculate Z positions from waste position coordinates - max_z_hamilton = max(z_positions_mm) # Highest waste position Z in Hamilton coordinates - waste_z_hamilton = max_z_hamilton - - # Calculate from waste position: start above waste position - z_start_absolute_mm = waste_z_hamilton + 4.0 # Start 4mm above waste position - - # Calculate from waste position: stop at waste position - z_stop_absolute_mm = waste_z_hamilton # Stop at waste position - - if z_position_at_end_of_a_command is None: - z_position_at_end_of_a_command = ( - self._channel_traversal_height - ) # Use traverse height as final position - - if roll_distance is None: - roll_distance = 9.0 # Default roll distance from log - - # Use absolute Z positions (same for all channels) - begin_tip_deposit_process = [round(z_start_absolute_mm * 100)] * len(use_channels) - end_tip_deposit_process = [round(z_stop_absolute_mm * 100)] * len(use_channels) - z_position_at_end_of_a_command_list = [round(z_position_at_end_of_a_command * 100)] * len( - use_channels - ) - roll_distances = [round(roll_distance * 100)] * len(use_channels) - - # Ensure arrays match num_channels length (with zeros for inactive channels) - x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) - y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) - begin_tip_deposit_process_full = self._fill_by_channels( - begin_tip_deposit_process, use_channels, default=0 - ) - end_tip_deposit_process_full = self._fill_by_channels( - end_tip_deposit_process, use_channels, default=0 - ) - z_position_at_end_of_a_command_full = self._fill_by_channels( - z_position_at_end_of_a_command_list, use_channels, default=0 - ) - roll_distances_full = self._fill_by_channels(roll_distances, use_channels, default=0) - - return ( - x_positions_full, - y_positions_full, - begin_tip_deposit_process_full, - end_tip_deposit_process_full, - z_position_at_end_of_a_command_full, - roll_distances_full, - ) - - # ============== Abstract methods from LiquidHandlerBackend ============== - - def _compute_ops_xy_locations( - self, ops: Sequence[PipettingOp], use_channels: List[int] - ) -> Tuple[List[int], List[int]]: - """Compute X and Y positions in Hamilton coordinates for the given operations.""" - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - x_positions_mm: List[float] = [] - y_positions_mm: List[float] = [] - - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) - final_location = abs_location + op.offset - hamilton_coord = self.deck.to_hamilton_coordinate(final_location) - - x_positions_mm.append(hamilton_coord.x) - y_positions_mm.append(hamilton_coord.y) - - # Convert positions to 0.01mm units (multiply by 100) - x_positions = [round(x * 100) for x in x_positions_mm] - y_positions = [round(y * 100) for y in y_positions_mm] - - x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) - y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) - - return x_positions_full, y_positions_full - - def _compute_tip_handling_parameters( - self, - ops: Sequence[Union[Pickup, Drop]], - use_channels: List[int], - use_fixed_offset: bool = False, - fixed_offset_mm: float = 10.0, - ): - """Calculate Z positions for tip pickup/drop operations. - - Pickup (use_fixed_offset=False): Z based on tip length - z_start = max_z + max_total_tip_length, z_stop = max_z + max_tip_length - Drop (use_fixed_offset=True): Z based on fixed offset (matches VantageBackend default) - z_start = max_z + fixed_offset_mm (default 10.0mm), z_stop = max_z - - Returns: (begin_position, end_position) in 0.01mm units - """ - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - z_positions_mm: List[float] = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - z_positions_mm.append(hamilton_coord.z) - - max_z_hamilton = max(z_positions_mm) # Highest resource Z in Hamilton coordinates - - if use_fixed_offset: - # For drop operations: use fixed offsets relative to resource surface - begin_position_mm = max_z_hamilton + fixed_offset_mm - end_position_mm = max_z_hamilton - else: - # For pickup operations: use tip length - # Similar to STAR backend: z_start = max_z + max_total_tip_length, z_stop = max_z + max_tip_length - 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_position_mm = max_z_hamilton + max_total_tip_length - end_position_mm = max_z_hamilton + max_tip_length - - # Convert to 0.01mm units - begin_position = [round(begin_position_mm * 100)] * len(ops) - end_position = [round(end_position_mm * 100)] * len(ops) - - begin_position_full = self._fill_by_channels(begin_position, use_channels, default=0) - end_position_full = self._fill_by_channels(end_position, use_channels, default=0) - - return begin_position_full, end_position_full - - async def pick_up_tips( - self, - ops: List[Pickup], - use_channels: List[int], - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - ): - """Pick up tips from the specified resource. - - TODO: evaluate this doc: - Z positions and traverse height are calculated from the resource locations and tip - properties if not explicitly provided: - - minimum_traverse_height_at_beginning_of_a_command: Uses deck z_max if not provided - - z_start_offset: Calculated as max(resource Z) + max(tip total_tip_length) - - z_stop_offset: Calculated as max(resource Z) + max(tip total_tip_length - tip fitting_depth) - - Args: - ops: List of Pickup operations, one per channel - use_channels: List of channel indices to use - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to _channel_traversal_height) - - Raises: - RuntimeError: If pipette address or deck is not set - ValueError: If deck is not a NimbusDeck and minimum_traverse_height_at_beginning_of_a_command is not provided - """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - # Check tip presence before picking up tips - try: - tip_present = await self.request_tip_presence() - channels_with_tips = [ - i for i, present in enumerate(tip_present) if i in use_channels and present - ] - if channels_with_tips: - raise RuntimeError( - f"Cannot pick up tips: channels {channels_with_tips} already have tips mounted. " - f"Drop existing tips first." - ) - except RuntimeError: - raise - except Exception as e: - # If tip presence check fails, log warning but continue - logger.warning(f"Could not check tip presence before pickup: {e}") - - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - begin_tip_pick_up_process, end_tip_pick_up_process = self._compute_tip_handling_parameters( - ops, use_channels - ) - - # Build tip pattern array (True for active channels, False for inactive) - channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] - - # Ensure arrays match num_channels length (pad with 0s for inactive channels) - tip_types = [_get_tip_type_from_tip(op.tip) for op in ops] - tip_types_full = self._fill_by_channels(tip_types, use_channels, default=0) - - # Traverse height: use default value - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) # Convert to 0.01mm units - - # Create and send command - command = PickupTips( - dest=self._pipette_address, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - begin_tip_pick_up_process=begin_tip_pick_up_process, - end_tip_pick_up_process=end_tip_pick_up_process, - tip_types=tip_types_full, - ) - - try: - await self.send_command(command) - logger.info(f"Picked up tips on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to pick up tips: {e}") - raise - - async def drop_tips( - self, - ops: List[Drop], - use_channels: List[int], - default_waste: bool = False, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - z_position_at_end_of_a_command: Optional[float] = None, - roll_distance: Optional[float] = None, - ): - """Drop tips to the specified resource. - - Auto-detects waste positions and uses appropriate command: - - If resource is a waste position (Trash with category="waste_position"), uses DropTipsRoll - - Otherwise, uses DropTips command - - Z positions are calculated from resource locations: - - For waste positions: Fixed Z positions (135.39 mm start, 131.39 mm stop) via _build_waste_position_params - - For regular resources: Fixed offsets relative to resource surface (max_z + 10mm start, max_z stop) - Note: Z positions use fixed offsets, NOT tip length, because the tip is already mounted on the pipette. - This works for all tip sizes (300ul, 1000ul, etc.) without additional configuration. - - z_position_at_end_of_a_command: Calculated from resources (defaults to minimum_traverse_height_at_beginning_of_a_command) - - roll_distance: Defaults to 9.0 mm for waste positions - - Args: - ops: List of Drop operations, one per channel - use_channels: List of channel indices to use - default_waste: For DropTips command, if True, drop to default waste (positions may be ignored) - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to self._channel_traversal_height) - z_position_at_end_of_a_command: Z final position in mm (absolute, optional, calculated from resources) - roll_distance: Roll distance in mm (optional, defaults to 9.0 mm for waste positions) - - Raises: - RuntimeError: If pipette address or deck is not set - ValueError: If operations mix waste and regular resources - """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - # Check if resources are waste positions (Trash objects) - is_waste_positions = [isinstance(op.resource, Trash) for op in ops] - all_waste = all(is_waste_positions) - all_regular = not any(is_waste_positions) - - if not (all_waste or all_regular): - raise ValueError( - "Cannot mix waste positions and regular resources in a single drop_tips call. " - "All operations must be either waste positions or regular resources." - ) - - # Build tip pattern array (1 for active channels, 0 for inactive) - channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] - - # Traverse height: use provided value (defaults to class attribute) - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) - - # Type annotation for command variable (can be either DropTips or DropTipsRoll) - command: Union[DropTips, DropTipsRoll] - - if all_waste: - # Use DropTipsRoll for waste positions - # Build waste position parameters using helper method - ( - x_positions_full, - y_positions_full, - begin_tip_deposit_process_full, - end_tip_deposit_process_full, - z_position_at_end_of_a_command_full, - roll_distances_full, - ) = self._build_waste_position_params( - use_channels=use_channels, - z_position_at_end_of_a_command=z_position_at_end_of_a_command, - roll_distance=roll_distance, - ) - - command = DropTipsRoll( - dest=self._pipette_address, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - begin_tip_deposit_process=begin_tip_deposit_process_full, - end_tip_deposit_process=end_tip_deposit_process_full, - z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, - roll_distances=roll_distances_full, - ) - - else: - # Compute x and y positions for regular resources - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - - # Compute Z positions using fixed offsets (not tip length) for drop operations - begin_tip_deposit_process, end_tip_deposit_process = self._compute_tip_handling_parameters( - ops, use_channels, use_fixed_offset=True - ) - - # Compute final Z positions. Use the traverse height if not provided. Fill to num_channels. - if z_position_at_end_of_a_command is None: - z_position_at_end_of_a_command_value = ( - minimum_traverse_height_at_beginning_of_a_command # Use traverse height as final position - ) - z_position_at_end_of_a_command_list = [ - round(z_position_at_end_of_a_command_value * 100) - ] * len(ops) # in 0.01mm units - z_position_at_end_of_a_command_full = self._fill_by_channels( - z_position_at_end_of_a_command_list, use_channels, default=0 - ) - - command = DropTips( - dest=self._pipette_address, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - begin_tip_deposit_process=begin_tip_deposit_process, - end_tip_deposit_process=end_tip_deposit_process, - z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, - default_waste=default_waste, - ) - - try: - await self.send_command(command) - logger.info(f"Dropped tips on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to drop tips: {e}") - raise - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - adc_enabled: bool = False, - # Advanced kwargs (Optional, default to zeros/nulls) - lld_mode: Optional[List[int]] = None, - lld_search_height: Optional[List[float]] = None, - immersion_depth: Optional[List[float]] = None, - surface_following_distance: Optional[List[float]] = None, - gamma_lld_sensitivity: Optional[List[int]] = None, - dp_lld_sensitivity: Optional[List[int]] = None, - settling_time: Optional[List[float]] = None, - transport_air_volume: Optional[List[float]] = None, - pre_wetting_volume: Optional[List[float]] = None, - swap_speed: Optional[List[float]] = None, - mix_position_from_liquid_surface: Optional[List[float]] = None, - limit_curve_index: Optional[List[int]] = None, - tadm_enabled: bool = False, - ): - """Aspirate liquid from the specified resource using pip. - - Args: - ops: List of SingleChannelAspiration operations, one per channel - use_channels: List of channel indices to use - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to self._channel_traversal_height) - adc_enabled: If True, enable ADC (Automatic Drip Control), else disable (default: False) - lld_mode: LLD mode (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL), default: [0] * n - lld_search_height: Relative offset from well bottom for LLD search start position (mm). - This is a RELATIVE OFFSET, not an absolute coordinate. The instrument adds this to - minimum_height (well bottom) to determine where to start the LLD search. - If None, defaults to the well's size_z (depth), meaning "start search at top of well". - When provided, should be a list of offsets in mm, one per channel. - immersion_depth: Depth to submerge into liquid (mm), default: [0.0] * n - surface_following_distance: Distance to follow liquid surface (mm), default: [0.0] * n - gamma_lld_sensitivity: Gamma LLD sensitivity (1-4), default: [0] * n - dp_lld_sensitivity: DP LLD sensitivity (1-4), default: [0] * n - settling_time: Settling time (s), default: [1.0] * n - transport_air_volume: Transport air volume (uL), default: [5.0] * n - pre_wetting_volume: Pre-wetting volume (uL), default: [0.0] * n - swap_speed: Swap speed on leaving liquid (uL/s), default: [20.0] * n - mix_position_from_liquid_surface: Mix position from liquid surface (mm), default: [0.0] * n - limit_curve_index: Limit curve index, default: [0] * n - tadm_enabled: TADM enabled flag, default: False - - Raises: - RuntimeError: If pipette address or deck is not set - """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - n = len(ops) - - # Build tip pattern array (1 for active channels, 0 for inactive) - channels_involved = [0] * self.num_channels - for channel_idx in use_channels: - if channel_idx >= self.num_channels: - raise ValueError(f"Channel index {channel_idx} exceeds num_channels {self.num_channels}") - channels_involved[channel_idx] = 1 - - # Call ADC command (EnableADC or DisableADC) - if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) - logger.info("Enabled ADC before aspirate") - else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) - logger.info("Disabled ADC before aspirate") - - # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") - if self._channel_configurations is None: - self._channel_configurations = {} - for channel_idx in use_channels: - channel_num = channel_idx + 1 # Convert to 1-based - try: - config = await self.send_command( - GetChannelConfiguration( - self._pipette_address, - channel=channel_num, - indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" - ) - ) - assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False - if channel_num not in self._channel_configurations: - self._channel_configurations[channel_num] = {} - self._channel_configurations[channel_num][2] = enabled - logger.debug(f"Channel {channel_num} configuration (index 2): enabled={enabled}") - except Exception as e: - logger.warning(f"Failed to get channel configuration for channel {channel_num}: {e}") - - # ======================================================================== - # MINIMAL SET: Calculate from resources (NOT kwargs) - # ======================================================================== - - # Extract coordinates and convert to Hamilton coordinates - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - - # Traverse height: use provided value or default - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) - - # Calculate well_bottoms: resource Z + offset Z + material_z_thickness in Hamilton coords - well_bottoms = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - if isinstance(op.resource, Container): - abs_location.z += op.resource.material_z_thickness - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - well_bottoms.append(hamilton_coord.z) - - # Calculate liquid_height: well_bottom + (op.liquid_height or 0) - # This is the fixed Z-height when LLD is OFF - liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] - - # Calculate lld_search_height if not provided as kwarg - # - # IMPORTANT: lld_search_height is a RELATIVE OFFSET (in mm), not an absolute coordinate. - # It represents the height offset from the well bottom where the LLD (Liquid Level Detection) - # search should start. The Hamilton instrument will add this offset to minimum_height - # (well bottom) to determine the absolute Z position where the search begins. - # - # Default behavior: Use the well's size_z (depth) as the offset, which means - # "start the LLD search at the top of the well" (well_bottom + well_size). - # This is a reasonable default since we want to search from the top downward. - # - # When provided as a kwarg, it should be a list of relative offsets in mm. - # The instrument will internally add these to minimum_height to get absolute coordinates. - if lld_search_height is None: - lld_search_height = [op.resource.get_absolute_size_z() for op in ops] - - # Calculate minimum_height: default to well_bottom - minimum_heights_mm = well_bottoms.copy() - - # Extract volumes and speeds from operations - volumes = [op.volume for op in ops] # in uL - flow_rates: List[float] = [ - op.flow_rate if op.flow_rate is not None else _get_default_flow_rate(op.tip, is_aspirate=True) - for op in ops - ] - blow_out_air_volumes = [ - op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops - ] # in uL, default 40 - - # Extract mix parameters from op.mix if available. Otherwise use None. - mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - # Default mix_speed to aspirate speed (flow_rates) when no mix operation - # This matches the working version behavior - mix_speed: List[float] = [ - op.mix.flow_rate - if op.mix is not None - else ( - op.flow_rate - if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=True) - ) - for op in ops - ] - - # ======================================================================== - # ADVANCED PARAMETERS: Fill in defaults using fill_in_defaults() - # ======================================================================== - - lld_mode = fill_in_defaults(lld_mode, [0] * n) - immersion_depth = fill_in_defaults(immersion_depth, [0.0] * n) - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [0] * n) - dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [0] * n) - settling_time = fill_in_defaults(settling_time, [1.0] * n) - transport_air_volume = fill_in_defaults(transport_air_volume, [5.0] * n) - pre_wetting_volume = fill_in_defaults(pre_wetting_volume, [0.0] * n) - swap_speed = fill_in_defaults(swap_speed, [20.0] * n) - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - - # ======================================================================== - # CONVERT UNITS AND BUILD FULL ARRAYS - # Hamilton uses units of 0.1uL and 0.1mm and 0.1s etc. for most parameters - # Some are in 0.01. - # PLR units are uL, mm, s etc. - # ======================================================================== - - aspirate_volumes = [round(vol * 10) for vol in volumes] - blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] - aspiration_speeds = [round(fr * 10) for fr in flow_rates] - lld_search_height_units = [round(h * 100) for h in lld_search_height] - liquid_height_units = [round(h * 100) for h in liquid_heights_mm] - immersion_depth_units = [round(d * 100) for d in immersion_depth] - surface_following_distance_units = [round(d * 100) for d in surface_following_distance] - minimum_height_units = [round(z * 100) for z in minimum_heights_mm] - settling_time_units = [round(t * 10) for t in settling_time] - transport_air_volume_units = [round(v * 10) for v in transport_air_volume] - pre_wetting_volume_units = [round(v * 10) for v in pre_wetting_volume] - swap_speed_units = [round(s * 10) for s in swap_speed] - mix_volume_units = [round(v * 10) for v in mix_volume] - mix_speed_units = [round(s * 10) for s in mix_speed] - mix_position_from_liquid_surface_units = [ - round(p * 100) for p in mix_position_from_liquid_surface - ] - - # Build arrays for all channels (pad with 0s for inactive channels) - aspirate_volumes_full = self._fill_by_channels(aspirate_volumes, use_channels, default=0) - blow_out_air_volumes_full = self._fill_by_channels( - blow_out_air_volumes_units, use_channels, default=0 - ) - aspiration_speeds_full = self._fill_by_channels(aspiration_speeds, use_channels, default=0) - lld_search_height_full = self._fill_by_channels( - lld_search_height_units, use_channels, default=0 - ) - liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) - immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) - surface_following_distance_full = self._fill_by_channels( - surface_following_distance_units, use_channels, default=0 - ) - minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) - settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) - transport_air_volume_full = self._fill_by_channels( - transport_air_volume_units, use_channels, default=0 - ) - pre_wetting_volume_full = self._fill_by_channels( - pre_wetting_volume_units, use_channels, default=0 - ) - swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) - mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) - mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) - mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) - mix_position_from_liquid_surface_full = self._fill_by_channels( - mix_position_from_liquid_surface_units, use_channels, default=0 - ) - gamma_lld_sensitivity_full = self._fill_by_channels( - gamma_lld_sensitivity, use_channels, default=0 - ) - dp_lld_sensitivity_full = self._fill_by_channels(dp_lld_sensitivity, use_channels, default=0) - limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) - lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) - - # Default values for remaining parameters - aspirate_type = [0] * self.num_channels - clot_detection_height = [0] * self.num_channels - min_z_endpos = minimum_traverse_height_at_beginning_of_a_command_units - mix_surface_following_distance = [0] * self.num_channels - tube_section_height = [0] * self.num_channels - tube_section_ratio = [0] * self.num_channels - lld_height_difference = [0] * self.num_channels - recording_mode = 0 - - # Create and send Aspirate command - command = Aspirate( - dest=self._pipette_address, - aspirate_type=aspirate_type, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - lld_search_height=lld_search_height_full, - liquid_height=liquid_height_full, - immersion_depth=immersion_depth_full, - surface_following_distance=surface_following_distance_full, - minimum_height=minimum_height_full, - clot_detection_height=clot_detection_height, - min_z_endpos=min_z_endpos, - swap_speed=swap_speed_full, - blow_out_air_volume=blow_out_air_volumes_full, - pre_wetting_volume=pre_wetting_volume_full, - aspirate_volume=aspirate_volumes_full, - transport_air_volume=transport_air_volume_full, - aspiration_speed=aspiration_speeds_full, - settling_time=settling_time_full, - mix_volume=mix_volume_full, - mix_cycles=mix_cycles_full, - mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, - mix_surface_following_distance=mix_surface_following_distance, - mix_speed=mix_speed_full, - tube_section_height=tube_section_height, - tube_section_ratio=tube_section_ratio, - lld_mode=lld_mode_full, - gamma_lld_sensitivity=gamma_lld_sensitivity_full, - dp_lld_sensitivity=dp_lld_sensitivity_full, - lld_height_difference=lld_height_difference, - tadm_enabled=tadm_enabled, - limit_curve_index=limit_curve_index_full, - recording_mode=recording_mode, - ) - - try: - await self.send_command(command) - logger.info(f"Aspirated on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to aspirate: {e}") - raise - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - adc_enabled: bool = False, - # Advanced kwargs (Optional, default to zeros/nulls) - lld_mode: Optional[List[int]] = None, - lld_search_height: Optional[List[float]] = None, - immersion_depth: Optional[List[float]] = None, - surface_following_distance: Optional[List[float]] = None, - gamma_lld_sensitivity: Optional[List[int]] = None, - settling_time: Optional[List[float]] = None, - transport_air_volume: Optional[List[float]] = None, - swap_speed: Optional[List[float]] = None, - mix_position_from_liquid_surface: Optional[List[float]] = None, - limit_curve_index: Optional[List[int]] = None, - tadm_enabled: bool = False, - cut_off_speed: Optional[List[float]] = None, - stop_back_volume: Optional[List[float]] = None, - side_touch_off_distance: float = 0.0, - dispense_offset: Optional[List[float]] = None, - ): - """Dispense liquid from the specified resource using pip. - - Args: - ops: List of SingleChannelDispense operations, one per channel - use_channels: List of channel indices to use - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to self._channel_traversal_height) - adc_enabled: If True, enable ADC (Automatic Drip Control), else disable (default: False) - lld_mode: LLD mode (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL), default: [0] * n - lld_search_height: Override calculated LLD search height (mm). If None, calculated from well_bottom + resource size - immersion_depth: Depth to submerge into liquid (mm), default: [0.0] * n - surface_following_distance: Distance to follow liquid surface (mm), default: [0.0] * n - gamma_lld_sensitivity: Gamma LLD sensitivity (1-4), default: [0] * n - settling_time: Settling time (s), default: [1.0] * n - transport_air_volume: Transport air volume (uL), default: [5.0] * n - swap_speed: Swap speed on leaving liquid (uL/s), default: [20.0] * n - mix_position_from_liquid_surface: Mix position from liquid surface (mm), default: [0.0] * n - limit_curve_index: Limit curve index, default: [0] * n - tadm_enabled: TADM enabled flag, default: False - cut_off_speed: Cut off speed (uL/s), default: [25.0] * n - stop_back_volume: Stop back volume (uL), default: [0.0] * n - side_touch_off_distance: Side touch off distance (mm), default: 0.0 - dispense_offset: Dispense offset (mm), default: [0.0] * n - - Raises: - RuntimeError: If pipette address or deck is not set - """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - n = len(ops) - - # Build tip pattern array (1 for active channels, 0 for inactive) - channels_involved = [0] * self.num_channels - for channel_idx in use_channels: - if channel_idx >= self.num_channels: - raise ValueError(f"Channel index {channel_idx} exceeds num_channels {self.num_channels}") - channels_involved[channel_idx] = 1 - - # Call ADC command (EnableADC or DisableADC) - if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) - logger.info("Enabled ADC before dispense") - else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) - logger.info("Disabled ADC before dispense") - - # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") - if self._channel_configurations is None: - self._channel_configurations = {} - for channel_idx in use_channels: - channel_num = channel_idx + 1 # Convert to 1-based - try: - config = await self.send_command( - GetChannelConfiguration( - self._pipette_address, - channel=channel_num, - indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" - ) - ) - assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False - if channel_num not in self._channel_configurations: - self._channel_configurations[channel_num] = {} - self._channel_configurations[channel_num][2] = enabled - logger.debug(f"Channel {channel_num} configuration (index 2): enabled={enabled}") - except Exception as e: - logger.warning(f"Failed to get channel configuration for channel {channel_num}: {e}") - - # ======================================================================== - # MINIMAL SET: Calculate from resources (NOT kwargs) - # ======================================================================== - - # Extract coordinates and convert to Hamilton coordinates - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - - # Traverse height: use provided value or default - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) - - # Calculate well_bottoms: resource Z + offset Z + material_z_thickness in Hamilton coords - well_bottoms = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - if isinstance(op.resource, Container): - abs_location.z += op.resource.material_z_thickness - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - well_bottoms.append(hamilton_coord.z) - - # Calculate liquid_height: well_bottom + (op.liquid_height or 0) - # This is the fixed Z-height when LLD is OFF - liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] - - # Calculate lld_search_height if not provided as kwarg - # - # IMPORTANT: lld_search_height is a RELATIVE OFFSET (in mm), not an absolute coordinate. - # It represents the height offset from the well bottom where the LLD (Liquid Level Detection) - # search should start. The Hamilton instrument will add this offset to minimum_height - # (well bottom) to determine the absolute Z position where the search begins. - # - # Default behavior: Use the well's size_z (depth) as the offset, which means - # "start the LLD search at the top of the well" (well_bottom + well_size). - # This is a reasonable default since we want to search from the top downward. - # - # When provided as a kwarg, it should be a list of relative offsets in mm. - # The instrument will internally add these to minimum_height to get absolute coordinates. - if lld_search_height is None: - lld_search_height = [op.resource.get_absolute_size_z() for op in ops] - - # Calculate minimum_height: default to well_bottom - minimum_heights_mm = well_bottoms.copy() - - # Extract volumes and speeds from operations - volumes = [op.volume for op in ops] # in uL - flow_rates: List[float] = [ - op.flow_rate - if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=False) - for op in ops - ] - blow_out_air_volumes = [ - op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops - ] # in uL, default 40 - - # Extract mix parameters from op.mix if available - mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - # Default mix_speed to dispense speed (flow_rates) when no mix operation - # This matches the working version behavior - mix_speed: List[float] = [ - op.mix.flow_rate - if op.mix is not None - else ( - op.flow_rate - if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=False) - ) - for op in ops - ] - - # ======================================================================== - # ADVANCED PARAMETERS: Fill in defaults using fill_in_defaults() - # ======================================================================== - - lld_mode = fill_in_defaults(lld_mode, [0] * n) - immersion_depth = fill_in_defaults(immersion_depth, [0.0] * n) - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [0] * n) - settling_time = fill_in_defaults(settling_time, [1.0] * n) - transport_air_volume = fill_in_defaults(transport_air_volume, [5.0] * n) - swap_speed = fill_in_defaults(swap_speed, [20.0] * n) - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - cut_off_speed = fill_in_defaults(cut_off_speed, [25.0] * n) - stop_back_volume = fill_in_defaults(stop_back_volume, [0.0] * n) - dispense_offset = fill_in_defaults(dispense_offset, [0.0] * n) - - # ======================================================================== - # CONVERT UNITS AND BUILD FULL ARRAYS - # Hamilton uses units of 0.1uL and 0.1mm and 0.1s etc. for most parameters - # Some are in 0.01. - # PLR units are uL, mm, s etc. - # ======================================================================== - - dispense_volumes = [round(vol * 10) for vol in volumes] - blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] - dispense_speeds = [round(fr * 10) for fr in flow_rates] - lld_search_height_units = [round(h * 100) for h in lld_search_height] - liquid_height_units = [round(h * 100) for h in liquid_heights_mm] - immersion_depth_units = [round(d * 100) for d in immersion_depth] - surface_following_distance_units = [round(d * 100) for d in surface_following_distance] - minimum_height_units = [round(z * 100) for z in minimum_heights_mm] - settling_time_units = [round(t * 10) for t in settling_time] - transport_air_volume_units = [round(v * 10) for v in transport_air_volume] - swap_speed_units = [round(s * 10) for s in swap_speed] - mix_volume_units = [round(v * 10) for v in mix_volume] - mix_speed_units = [round(s * 10) for s in mix_speed] - mix_position_from_liquid_surface_units = [ - round(p * 100) for p in mix_position_from_liquid_surface - ] - cut_off_speed_units = [round(s * 10) for s in cut_off_speed] - stop_back_volume_units = [round(v * 10) for v in stop_back_volume] - dispense_offset_units = [round(o * 100) for o in dispense_offset] - side_touch_off_distance_units = round(side_touch_off_distance * 100) - - # Build arrays for all channels (pad with 0s for inactive channels) - dispense_volumes_full = self._fill_by_channels(dispense_volumes, use_channels, default=0) - blow_out_air_volumes_full = self._fill_by_channels( - blow_out_air_volumes_units, use_channels, default=0 - ) - dispense_speeds_full = self._fill_by_channels(dispense_speeds, use_channels, default=0) - lld_search_height_full = self._fill_by_channels( - lld_search_height_units, use_channels, default=0 - ) - liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) - immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) - surface_following_distance_full = self._fill_by_channels( - surface_following_distance_units, use_channels, default=0 - ) - minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) - settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) - transport_air_volume_full = self._fill_by_channels( - transport_air_volume_units, use_channels, default=0 - ) - swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) - mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) - mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) - mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) - mix_position_from_liquid_surface_full = self._fill_by_channels( - mix_position_from_liquid_surface_units, use_channels, default=0 - ) - gamma_lld_sensitivity_full = self._fill_by_channels( - gamma_lld_sensitivity, use_channels, default=0 - ) - limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) - lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) - cut_off_speed_full = self._fill_by_channels(cut_off_speed_units, use_channels, default=0) - stop_back_volume_full = self._fill_by_channels(stop_back_volume_units, use_channels, default=0) - dispense_offset_full = self._fill_by_channels(dispense_offset_units, use_channels, default=0) - - # Default values for remaining parameters - dispense_type = [0] * self.num_channels - min_z_endpos = minimum_traverse_height_at_beginning_of_a_command_units - mix_surface_following_distance = [0] * self.num_channels - tube_section_height = [0] * self.num_channels - tube_section_ratio = [0] * self.num_channels - recording_mode = 0 - - # Create and send Dispense command - command = Dispense( - dest=self._pipette_address, - dispense_type=dispense_type, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - lld_search_height=lld_search_height_full, - liquid_height=liquid_height_full, - immersion_depth=immersion_depth_full, - surface_following_distance=surface_following_distance_full, - minimum_height=minimum_height_full, - min_z_endpos=min_z_endpos, - swap_speed=swap_speed_full, - transport_air_volume=transport_air_volume_full, - dispense_volume=dispense_volumes_full, - stop_back_volume=stop_back_volume_full, - blow_out_air_volume=blow_out_air_volumes_full, - dispense_speed=dispense_speeds_full, - cut_off_speed=cut_off_speed_full, - settling_time=settling_time_full, - mix_volume=mix_volume_full, - mix_cycles=mix_cycles_full, - mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, - mix_surface_following_distance=mix_surface_following_distance, - mix_speed=mix_speed_full, - side_touch_off_distance=side_touch_off_distance_units, - dispense_offset=dispense_offset_full, - tube_section_height=tube_section_height, - tube_section_ratio=tube_section_ratio, - lld_mode=lld_mode_full, - gamma_lld_sensitivity=gamma_lld_sensitivity_full, - tadm_enabled=tadm_enabled, - limit_curve_index=limit_curve_index_full, - recording_mode=recording_mode, - ) - - try: - await self.send_command(command) - logger.info(f"Dispensed on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to dispense: {e}") - raise - - async def pick_up_tips96(self, pickup: PickupTipRack): - raise NotImplementedError("pick_up_tips96 not yet implemented") - - async def drop_tips96(self, drop: DropTipRack): - raise NotImplementedError("drop_tips96 not yet implemented") - - async def aspirate96(self, aspiration: MultiHeadAspirationPlate | MultiHeadAspirationContainer): - raise NotImplementedError("aspirate96 not yet implemented") - - async def dispense96(self, dispense: MultiHeadDispensePlate | MultiHeadDispenseContainer): - raise NotImplementedError("dispense96 not yet implemented") - - async def pick_up_resource(self, pickup: ResourcePickup): - raise NotImplementedError("pick_up_resource not yet implemented") - - async def move_picked_up_resource(self, move: ResourceMove): - raise NotImplementedError("move_picked_up_resource not yet implemented") - - async def drop_resource(self, drop: ResourceDrop): - raise NotImplementedError("drop_resource not yet implemented") - - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - """Check if the tip can be picked up by the specified channel. - - Args: - channel_idx: Channel index (0-based) - tip: Tip object to check - - Returns: - True if the tip can be picked up, False otherwise - """ - # Only Hamilton tips are supported - if not isinstance(tip, HamiltonTip): - return False - - # XL tips are not supported on Nimbus - if tip.tip_size in {TipSize.XL}: - return False - - # Check if channel index is valid - if self._num_channels is not None and channel_idx >= self._num_channels: - return False - - return True diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py deleted file mode 100644 index 434f33aa042..00000000000 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Shared code for Hamilton TCP-based backends such as the Nimbus and the Prep.""" diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend.py b/pylabrobot/liquid_handling/backends/opentrons_backend.py index f5e30322a9e..6b76758e082 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend.py @@ -1,709 +1,10 @@ -import uuid -from typing import Dict, List, Optional, Tuple, Union, cast +import warnings -from pylabrobot import utils -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 - - # for run cancellation - import ot_api.requestor as _req - - 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" - - -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 - - ot_api.set_host(host) - ot_api.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 = ot_api.runs.create() - ot_api.set_run(run_id) - - # get pipettes, then assign them - self.left_pipette, self.right_pipette = ot_api.lh.add_mounted_pipettes() - - self.left_pipette_has_tip = self.right_pipette_has_tip = False - - # get api version - health = ot_api.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(ot_api, "run_id", None) - if run_id: - try: - _req.post(f"/runs/{run_id}/cancel") - except Exception: - try: - _req.post(f"/runs/{run_id}/actions/cancel") - except Exception: - try: - _req.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 = ot_api.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 - assert isinstance(deck, OTDeck) - slot = deck.get_slot(tip_rack) - assert slot is not None, "tip rack must be on deck" - - ot_api.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 - - ot_api.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: - ot_api.lh.move_to_addressable_area_for_drop_tip( - pipette_id=pipette_id, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - ) - ot_api.lh.drop_tip_in_place(pipette_id=pipette_id) - else: - ot_api.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] - - 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 = ( - 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): - ot_api.lh.aspirate_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - ot_api.lh.dispense_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - - ot_api.lh.aspirate_in_place( - volume=volume, - flow_rate=flow_rate, - pipette_id=pipette_id, - ) - - traversal_location = ( - 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 = ( - 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, - ) - - ot_api.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): - ot_api.lh.aspirate_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - ot_api.lh.dispense_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - - traversal_location = ( - 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): - ot_api.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], ot_api.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 = ot_api.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.") - - ot_api.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/errors.py b/pylabrobot/liquid_handling/errors.py deleted file mode 100644 index 5ec290b1ab7..00000000000 --- a/pylabrobot/liquid_handling/errors.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Dict - - -class NoChannelError(Exception): - """Raised when no channel is available, e.g. when trying to pick up a tip with no empty channels. - This error is only raised when the channel is automatically selected by the system. - - Examples: - - when trying to pick up a tip with no empty channels available on a robot - """ - - -class ChannelsDoNotFitError(Exception): - """Raised when channels cannot be positioned within a resource's compartments while respecting - no-go zones and spacing constraints.""" - - -class ChannelizedError(Exception): - """Raised by operations that work on multiple channels: pick_up_tips, drop_tips, aspirate, and - dispense. Contains a key for each channel that had an error, and the error that occurred.""" - - def __init__(self, errors: Dict[int, Exception], **kwargs): - self.errors = errors - self.kwargs = kwargs - - def __str__(self) -> str: - kwarg_string = ", ".join([f"{k}={v}" for k, v in self.kwargs.items()]) - return f"ChannelizedError(errors={self.errors}, {kwarg_string})" - - def __len__(self) -> int: - return len(self.errors) 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/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py deleted file mode 100644 index ef194c5a3a8..00000000000 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ /dev/null @@ -1,2839 +0,0 @@ -"""Defines LiquidHandler class, the coordinator for liquid handling operations.""" - -from __future__ import annotations - -import contextlib -import inspect -import json -import logging -import unittest.mock -import warnings -from typing import ( - Any, - Awaitable, - Callable, - Dict, - Generator, - List, - Literal, - Optional, - Sequence, - Set, - Tuple, - Union, - cast, -) - -from pylabrobot.liquid_handling.channel_positioning import ( - compute_channel_offsets, -) -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.strictness import ( - Strictness, - get_strictness, -) -from pylabrobot.machines.machine import Machine, need_setup_finished -from pylabrobot.plate_reading import PlateReader -from pylabrobot.resources import ( - Container, - Coordinate, - Deck, - Lid, - Plate, - PlateAdapter, - PlateHolder, - Resource, - ResourceHolder, - ResourceStack, - Tip, - TipRack, - TipSpot, - TipTracker, - Trash, - Well, - does_tip_tracking, - does_volume_tracking, -) -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 ( - Drop, - DropTipRack, - GripDirection, - Mix, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, -) - -logger = logging.getLogger("pylabrobot") - - -TipPresenceProbingMethod = Callable[ - [List[TipSpot], Optional[List[int]]], - Awaitable[Dict[str, bool]], -] - - -class BlowOutVolumeError(Exception): - pass - - -class LiquidHandler(Resource, Machine): - """ - Front end for liquid handlers. - - This class is the front end for liquid handlers; it provides a high-level interface for - interacting with liquid handlers. In the background, this class uses the low-level backend ( - defined in `pyhamilton.liquid_handling.backends`) to communicate with the liquid handler. - """ - - def __init__( - self, - backend: LiquidHandlerBackend, - deck: Deck, - default_offset_head96: Optional[Coordinate] = None, - name: Optional[str] = None, - ): - """Initialize a LiquidHandler. - - Args: - backend: Backend to use. - deck: Deck to use. - default_offset_head96: Base offset applied to all 96-head operations. - name: Name of the liquid handler. If not provided, defaults to ``lh_{deck.name}``. - """ - - Resource.__init__( - self, - name=name if name is not None else f"lh_{deck.name}", - size_x=deck._size_x, - size_y=deck._size_y, - size_z=deck._size_z, - category="liquid_handler", - ) - Machine.__init__(self, backend=backend) - - self.backend: LiquidHandlerBackend = backend # fix type - - self.deck = deck - - self.head: Dict[int, TipTracker] = {} - self.head96: Dict[int, TipTracker] = {} - self._default_use_channels: Optional[List[int]] = None - - self._blow_out_air_volume: Optional[List[Optional[float]]] = None - - # Default offset applied to all 96-head operations. Any offset passed to a 96-head method is - # added to this value. - self.default_offset_head96: Coordinate = default_offset_head96 or Coordinate.zero() - - # assign deck as only child resource, and set location of self to origin. - self.location = Coordinate.zero() - super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) - - self._resource_pickups: Dict[int, Optional[ResourcePickup]] = {} - - @property - def _resource_pickup(self) -> Optional[ResourcePickup]: - """Backward-compatible access to the first arm's pickup state.""" - return self._resource_pickups.get(0) - - @_resource_pickup.setter - def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: - self._resource_pickups[0] = value - - async def setup(self, **backend_kwargs): - """Prepare the robot for use.""" - - if self.setup_finished: - raise RuntimeError("The setup has already finished. See `LiquidHandler.stop`.") - - self.backend.set_deck(self.deck) - self.backend.set_heads(head=self.head, head96=self.head96) - await super().setup(**backend_kwargs) - - self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} - - self.head96 = ( - {c: TipTracker(thing=f"Channel {c}") for c in range(96)} - if self.backend.head96_installed - else {} - ) - - self.backend.set_heads(head=self.head, head96=self.head96 or None) - - for tracker in self.head.values(): - tracker.register_callback(self._state_updated) - for tracker in self.head96.values(): - tracker.register_callback(self._state_updated) - - self._resource_pickups = {a: None for a in range(self.backend.num_arms)} - - def serialize_state(self) -> Dict[str, Any]: - """Serialize the state of this liquid handler. Use :meth:`~Resource.serialize_all_states` to - serialize the state of the liquid handler and all children (the deck).""" - - head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} - head96_state = ( - {channel: tracker.serialize() for channel, tracker in self.head96.items()} - if self.head96 - else None - ) - arm_state: Optional[Dict[int, Any]] - if self._resource_pickups: - arm_state = { - arm_id: serialize(pickup) if pickup is not None else None - for arm_id, pickup in self._resource_pickups.items() - } - else: - arm_state = None - return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} - - def load_state(self, state: Dict[str, Any]): - """Load the liquid handler state from a file. Use :meth:`~Resource.load_all_state` to load the - state of the liquid handler and all children (the deck).""" - - head_state = state["head_state"] - for channel, tracker_state in head_state.items(): - self.head[channel].load_state(tracker_state) - - head96_state = state.get("head96_state", {}) - if head96_state and self.head96: - for channel, tracker_state in head96_state.items(): - self.head96[channel].load_state(tracker_state) - - # arm_state is informational only (read via serialize_state); no load needed since - # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. - - def update_head_state(self, state: Dict[int, Optional[Tip]]): - """Update the state of the liquid handler head. - - All keys in `state` must be valid channels. Channels for which no key is specified will keep - their current state. - - Args: - state: A dictionary mapping channels to tips. If a channel is mapped to None, that channel - will have no tip. - """ - - assert set(state.keys()).issubset(set(self.head.keys())), "Invalid channel." - - for channel, tip in state.items(): - if tip is None: - if self.head[channel].has_tip: - self.head[channel].remove_tip() - else: - if self.head[channel].has_tip: # remove tip so we can update the head. - self.head[channel].remove_tip() - self.head[channel].add_tip(tip) - - def clear_head_state(self): - """Clear the state of the liquid handler head.""" - - self.update_head_state({c: None for c in self.head.keys()}) - - def summary(self): - """Prints a string summary of the deck layout.""" - - print(self.deck.summary()) - - def _assert_positions_unique(self, positions: List[str]): - """Returns whether all items in `positions` are unique where they are not `None`. - - Args: - positions: List of positions. - """ - - not_none = [p for p in positions if p is not None] - if len(not_none) != len(set(not_none)): - raise ValueError("Positions must be unique.") - - def _assert_resources_exist(self, resources: Sequence[Resource]): - """Checks that each resource in `resources` is assigned to the deck. - - Args: - resources: List of resources. - - Raises: - ValueError: If a resource is not assigned to the deck. - """ - - for resource in resources: - # names on the deck are unique, so we can simply check if the resource matches the one on - # the deck (if any). - resource_from_deck = self.deck.get_resource(resource.name) - # it might be better to use `is`, but that would probably cause problems with autoreload. - if not resource_from_deck == resource: - raise ValueError(f"Resource {resource} is not assigned to the deck.") - - def _check_args( - self, - method: Callable, - backend_kwargs: Dict[str, Any], - default: Set[str], - strictness: Strictness, - ) -> Set[str]: - """Checks that the arguments to `method` are valid. - - Args: - method: Method to check. - backend_kwargs: Keyword arguments to `method`. - default: Default arguments to `method`. (Of the abstract backend) - strictness: Strictness level. If `Strictness.STRICT`, raises an error if there are extra - arguments. If `Strictness.WARN`, raises a warning. If `Strictness.IGNORE`, logs a debug - message. - - Raises: - TypeError: If the arguments are invalid. - - Returns: - The set of arguments that need to be removed from `backend_kwargs` before passing to `method`. - """ - - # if method is an AsyncMock, skip the checks - if isinstance(method, unittest.mock.AsyncMock): - return set() - - default_args = default.union({"self"}) - - sig = inspect.signature(method) - args = {arg: param for arg, param in sig.parameters.items() if arg not in default_args} - vars_keyword = { - arg - for arg, param in sig.parameters.items() # **kwargs - if param.kind == inspect.Parameter.VAR_KEYWORD - } - args = { - arg: param - for arg, param in args.items() # keep only *args and **kwargs - if param.kind - not in { - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD, - } - } - non_default = {arg for arg, param in args.items() if param.default == inspect.Parameter.empty} - - backend_kws = set(backend_kwargs.keys()) - - missing = non_default - backend_kws - if len(missing) > 0: - raise TypeError(f"Missing arguments to backend.{method.__name__}: {missing}") - - if len(vars_keyword) > 0: - return set() # no extra arguments if the method accepts **kwargs - - extra = backend_kws - set(args.keys()) - if len(extra) > 0 and len(vars_keyword) == 0: - if strictness == Strictness.STRICT: - raise TypeError(f"Extra arguments to backend.{method.__name__}: {extra}") - elif strictness == Strictness.WARN: - warnings.warn(f"Extra arguments to backend.{method.__name__}: {extra}") - else: - logger.debug("Extra arguments to backend.%s: %s", method.__name__, extra) - - return extra - - def _compute_spread_offsets( - self, - resource: Resource, - use_channels: List[int], - spread: str, - ) -> List[Coordinate]: - """Compute channel spread offsets for a single-resource multi-channel operation.""" - return compute_channel_offsets( - resource=resource, - num_channels=len(use_channels), - spread=spread, - channel_spacings=self.backend.get_channel_spacings(use_channels), - ) - - def _make_sure_channels_exist(self, channels: List[int]): - """Checks that the channels exist.""" - invalid_channels = [c for c in channels if c not in self.head] - if not len(invalid_channels) == 0: - raise ValueError(f"Invalid channels: {invalid_channels}") - - def _format_param(self, value: Any) -> Any: - """Format parameters for logging.""" - if isinstance(value, Resource): - return value.name - try: - if isinstance(value, Sequence) and len(value) > 0 and isinstance(value[0], Resource): - return [v.name for v in value] - except Exception: - pass - return value - - def _log_command(self, name: str, **kwargs) -> None: - params = ", ".join(f"{k}={self._format_param(v)}" for k, v in kwargs.items()) - logger.debug("%s(%s)", name, params) - - def get_picked_up_resource(self) -> Optional[Resource]: - """Get the resource that is currently picked up. - - Returns: - The resource that is currently picked up, or `None` if no resource is being picked up. - """ - - if self._resource_pickup is None: - return None - return self._resource_pickup.resource - - @need_setup_finished - async def pick_up_tips( - self, - tip_spots: List[TipSpot], - use_channels: Optional[List[int]] = None, - offsets: Optional[List[Coordinate]] = None, - **backend_kwargs, - ): - """Pick up tips from a resource. - - Examples: - Pick up all tips in the first column. - - >>> await lh.pick_up_tips(tips_resource["A1":"H1"]) - - Pick up tips on odd numbered rows, skipping the other channels. - - >>> await lh.pick_up_tips(tips_resource["A1", "C1", "E1", "G1"],use_channels=[0, 2, 4, 6]) - - Pick up tips from different tip resources: - - >>> await lh.pick_up_tips(tips_resource1["A1"] + tips_resource2["B2"] + tips_resource3["C3"]) - - Picking up tips with different offsets: - - >>> await lh.pick_up_tips( - ... tip_spots=tips_resource["A1":"C1"], - ... offsets=[ - ... Coordinate(0, 0, 0), # A1 - ... Coordinate(1, 1, 1), # B1 - ... Coordinate.zero() # C1 - ... ] - ... ) - - Args: - tip_spots: List of tip spots to pick up tips from. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(channels)` channels will be used. - offsets: List of offsets, one for each channel: a translation that will be applied to the tip - drop location. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If the positions are not unique. - - HasTipError: If a channel already has a tip. - - NoTipError: If a spot does not have a tip. - """ - - self._log_command( - "pick_up_tips", - tip_spots=tip_spots, - use_channels=use_channels, - offsets=offsets, - ) - - not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, TipSpot)] - if len(not_tip_spots) > 0: - raise TypeError(f"Resources must be `TipSpot`s, got {not_tip_spots}") - - # fix arguments - use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - tips = [tip_spot.get_tip() for tip_spot in tip_spots] - - if not all( - self.backend.can_pick_up_tip(channel, tip) for channel, tip in zip(use_channels, tips) - ): - cannot = [ - channel - for channel, tip in zip(use_channels, tips) - if not self.backend.can_pick_up_tip(channel, tip) - ] - raise RuntimeError(f"Cannot pick up tips on channels {cannot}.") - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(tip_spots) - - # checks - self._assert_resources_exist(tip_spots) - self._make_sure_channels_exist(use_channels) - assert len(tip_spots) == len(offsets) == len(use_channels), ( - "Number of tips and offsets and use_channels must be equal." - ) - - # create operations - pickups = [ - Pickup(resource=tip_spot, offset=offset, tip=tip) - for tip_spot, offset, tip in zip(tip_spots, offsets, tips) - ] - - # queue operations on the trackers - for channel, op in zip(use_channels, pickups): - if self.head[channel].has_tip: - raise HasTipError("Channel has tip") - if does_tip_tracking() and not op.resource.tracker.is_disabled: - op.resource.tracker.remove_tip() - self.head[channel].add_tip(op.tip, origin=op.resource, commit=False) - - # fix the backend kwargs - extras = self._check_args( - self.backend.pick_up_tips, - backend_kwargs, - default={"ops", "use_channels"}, - strictness=get_strictness(), - ) - for extra in extras: - del backend_kwargs[extra] - - # actually pick up the tips - error: Optional[BaseException] = None - try: - await self.backend.pick_up_tips(ops=pickups, use_channels=use_channels, **backend_kwargs) - except BaseException as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(pickups) - if error is not None: - try: - tip_presence = await self.backend.request_tip_presence() - successes = [tip_presence[ch] is True for ch in use_channels] - except Exception as tip_presence_error: - if not isinstance(tip_presence_error, NotImplementedError): - logger.warning("Failed to query tip presence after error: %s", tip_presence_error) - if isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, pickups, successes): - if does_tip_tracking() and not op.resource.tracker.is_disabled: - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - (self.head[channel].commit if success else self.head[channel].rollback)() - - if error is not None: - raise error - - def get_mounted_tips(self) -> List[Optional[Tip]]: - """Get the tips currently mounted on the head. - - Returns: - A list of tips currently mounted on the head, or `None` for channels without a tip. - """ - return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] - - @need_setup_finished - async def drop_tips( - self, - tip_spots: Sequence[Union[TipSpot, Trash]], - use_channels: Optional[List[int]] = None, - offsets: Optional[List[Coordinate]] = None, - allow_nonzero_volume: bool = False, - **backend_kwargs, - ): - """Drop tips to a resource. - - Examples: - Dropping tips to the first column. - - >>> await lh.pick_up_tips(tip_rack["A1:H1"]) - - Dropping tips with different offsets: - - >>> await lh.drop_tips( - ... channels=tips_resource["A1":"C1"], - ... offsets=[ - ... Coordinate(0, 0, 0), # A1 - ... Coordinate(1, 1, 1), # B1 - ... Coordinate.zero() # C1 - ... ] - ... ) - - Args: - tip_spots: Tip resource locations to drop to. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(channels)` channels will be used. - offsets: List of offsets, one for each channel, a translation that will be applied to the tip - drop location. If `None`, no offset will be applied. - allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there - is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero - volume. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If no channel will pick up a tip, in other words, if all channels are `None` or - if the list of channels is empty. - - ValueError: If the positions are not unique. - - NoTipError: If a channel does not have a tip. - - HasTipError: If a spot already has a tip. - """ - - self._log_command( - "drop_tips", - tip_spots=tip_spots, - use_channels=use_channels, - offsets=offsets, - allow_nonzero_volume=allow_nonzero_volume, - ) - - not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, (TipSpot, Trash))] - if len(not_tip_spots) > 0: - raise TypeError(f"Resources must be `TipSpot`s or Trash, got {not_tip_spots}") - - # fix arguments - use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - tips = [] - for channel in use_channels: - tip = self.head[channel].get_tip() - if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume: - raise RuntimeError(f"Cannot drop tip with volume {tip.tracker.get_used_volume()}") - tips.append(tip) - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(tip_spots) - - # checks - self._assert_resources_exist(tip_spots) - self._make_sure_channels_exist(use_channels) - assert len(tip_spots) == len(offsets) == len(use_channels) == len(tips), ( - "Number of channels and offsets and use_channels and tips must be equal." - ) - - # create operations - drops = [ - Drop(resource=tip_spot, offset=offset, tip=tip) - for tip_spot, tip, offset in zip(tip_spots, tips, offsets) - ] - - # queue operations on the trackers - for channel, op in zip(use_channels, drops): - if ( - does_tip_tracking() - and isinstance(op.resource, TipSpot) - and not op.resource.tracker.is_disabled - ): - op.resource.tracker.add_tip(op.tip, commit=False) - self.head[channel].remove_tip() - - # fix the backend kwargs - extras = self._check_args( - self.backend.drop_tips, - backend_kwargs, - default={"ops", "use_channels"}, - strictness=get_strictness(), - ) - for extra in extras: - del backend_kwargs[extra] - - # actually drop the tips - error: Optional[BaseException] = None - try: - await self.backend.drop_tips(ops=drops, use_channels=use_channels, **backend_kwargs) - except BaseException as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(drops) - if error is not None: - try: - tip_presence = await self.backend.request_tip_presence() - successes = [tip_presence[ch] is False for ch in use_channels] - except Exception as tip_presence_error: - if not isinstance(tip_presence_error, NotImplementedError): - logger.warning("Failed to query tip presence after error: %s", tip_presence_error) - if isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, drops, successes): - if ( - does_tip_tracking() - and isinstance(op.resource, TipSpot) - and not op.resource.tracker.is_disabled - ): - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - (self.head[channel].commit if success else self.head[channel].rollback)() - - if error is not None: - raise error - - async def return_tips( - self, - use_channels: Optional[list[int]] = None, - allow_nonzero_volume: bool = False, - offsets: Optional[List[Coordinate]] = None, - **backend_kwargs, - ): - """Return all tips that are currently picked up to their original place. - - Examples: - Return the tips on the head to the tip rack where they were picked up: - - >>> await lh.pick_up_tips(tip_rack["A1"]) - >>> await lh.return_tips() - - Args: - use_channels: List of channels to use. Index from front to back. If `None`, all that have - tips will be used. - allow_nonzero_volume: If `True`, tips will be returned even if their volumes are not zero. - backend_kwargs: backend kwargs passed to `drop_tips`. - - Raises: - RuntimeError: If no tips have been picked up. - """ - - self._log_command( - "return_tips", - use_channels=use_channels, - allow_nonzero_volume=allow_nonzero_volume, - ) - - tip_spots: List[TipSpot] = [] - channels: List[int] = [] - - for channel, tracker in self.head.items(): - if use_channels is not None and channel not in use_channels: - continue - if tracker.has_tip: - origin = tracker.get_tip_origin() - if origin is None: - raise RuntimeError("No tip origin found.") - tip_spots.append(origin) - channels.append(channel) - - if len(tip_spots) == 0: - raise RuntimeError("No tips have been picked up.") - - return await self.drop_tips( - tip_spots=tip_spots, - use_channels=channels, - allow_nonzero_volume=allow_nonzero_volume, - offsets=offsets, - **backend_kwargs, - ) - - async def discard_tips( - self, - use_channels: Optional[List[int]] = None, - allow_nonzero_volume: bool = True, - offsets: Optional[List[Coordinate]] = None, - **backend_kwargs, - ): - """Permanently discard tips in the trash. - - Examples: - Discarding the tips on channels 1 and 2: - - >>> await lh.discard_tips(use_channels=[0, 1]) - - Discarding all tips currently picked up: - - >>> await lh.discard_tips() - - Args: - use_channels: List of channels to use. Index from front to back. If `None`, all that have - tips will be used. - allow_nonzero_volume: If `True`, tips will be returned even if their volumes are not zero. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ - - self._log_command( - "discard_tips", - use_channels=use_channels, - allow_nonzero_volume=allow_nonzero_volume, - offsets=offsets, - ) - - # Different default value from drop_tips: here we factor in the tip tracking. - if use_channels is None: - use_channels = [c for c, t in self.head.items() if t.has_tip] - - n = len(use_channels) - - if n == 0: - raise RuntimeError("No tips have been picked up and no channels were specified.") - - trash = self.deck.get_trash_area() - trash_offsets = compute_channel_offsets(trash, num_channels=n, spread="tight") - # add trash_offsets to offsets if defined, otherwise use trash_offsets - # too advanced for mypy - offsets = [ - o + to if o is not None else to - for o, to in zip(offsets or [None] * n, trash_offsets) # type: ignore - ] - - return await self.drop_tips( - tip_spots=[trash] * n, - use_channels=use_channels, - offsets=offsets, - allow_nonzero_volume=allow_nonzero_volume, - **backend_kwargs, - ) - - async def move_tips( - self, - source_tip_spots: List[TipSpot], - dest_tip_spots: List[TipSpot], - ): - """Move tips from one tip rack to another. - - This is a convenience method that picks up tips from `source_tip_spots` and drops them to - `dest_tip_spots`. - - Examples: - Move tips from one tip rack to another: - - >>> await lh.move_tips(source_tip_rack["A1":"A8"], dest_tip_rack["B1":"B8"]) - """ - - if len(source_tip_spots) != len(dest_tip_spots): - raise ValueError("Number of source and destination tip spots must match.") - - use_channels = list(range(len(source_tip_spots))) - - await self.pick_up_tips( - tip_spots=source_tip_spots, - use_channels=use_channels, - ) - await self.drop_tips( - tip_spots=dest_tip_spots, - use_channels=use_channels, - ) - - def _check_containers(self, resources: Sequence[Resource]): - """Checks that all resources are containers.""" - not_containers = [r for r in resources if not isinstance(r, Container)] - if len(not_containers) > 0: - raise TypeError(f"Resources must be `Container`s, got {not_containers}") - - @need_setup_finished - async def aspirate( - self, - resources: Sequence[Container], - vols: List[float], - use_channels: Optional[List[int]] = None, - flow_rates: Optional[List[Optional[float]]] = None, - offsets: Optional[List[Coordinate]] = None, - liquid_height: Optional[List[Optional[float]]] = None, - blow_out_air_volume: Optional[List[Optional[float]]] = None, - spread: Literal["wide", "tight", "custom"] = "wide", - mix: Optional[List[Mix]] = None, - **backend_kwargs, - ): - """Aspirate liquid from the specified wells. - - Examples: - Aspirate a constant amount of liquid from the first column: - - >>> await lh.aspirate(plate["A1:H1"], 50) - - Aspirate an linearly increasing amount of liquid from the first column: - - >>> await lh.aspirate(plate["A1:H1"], range(0, 500, 50)) - - Aspirate arbitrary amounts of liquid from the first column: - - >>> await lh.aspirate(plate["A1:H1"], [0, 40, 10, 50, 100, 200, 300, 400]) - - Aspirate liquid from wells in different plates: - - >>> await lh.aspirate(plate["A1"] + plate2["A1"] + plate3["A1"], 50) - - Aspirating with a 10mm z-offset: - - >>> await lh.aspirate(plate["A1"], vols=50, offsets=[Coordinate(0, 0, 10)]) - - Aspirate from a blue bucket (big container), with the first 4 channels (which will be - spaced equally apart): - - >>> await lh.aspirate(blue_bucket, vols=50, use_channels=[0, 1, 2, 3]) - - Args: - resources: A list of wells to aspirate liquid from. Can be a single resource, or a list of - resources. If a single resource is specified, all channels will aspirate from the same - resource. - vols: A list of volumes to aspirate, one for each channel. If `vols` is a single number, then - all channels will aspirate that volume. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(wells)` channels will be used. - flow_rates: the aspiration speed. In ul/s. If `None`, the backend default will be used. - offsets: List of offsets for each channel, a translation that will be applied to the - aspiration location. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. - blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the - backend default will be used. - spread: Used if aspirating from a single resource with multiple channels. If "tight", the - channels will be spaced as close as possible. If "wide", the channels will be spaced as far - apart as possible. If "custom", the user must specify the offsets wrt the center of the - resource. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If all channels are `None`. - """ - - self._log_command( - "aspirate", - resources=resources, - vols=vols, - use_channels=use_channels, - flow_rates=flow_rates, - offsets=offsets, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - ) - - self._check_containers(resources) - - use_channels = use_channels or self._default_use_channels or list(range(len(resources))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(use_channels) - flow_rates = flow_rates or [None] * len(use_channels) - liquid_height = liquid_height or [None] * len(use_channels) - blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) - - # Convert everything to floats to handle exotic number types - vols = [float(v) for v in vols] - flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] - liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] - blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] - - self._blow_out_air_volume = blow_out_air_volume - tips = [self.head[channel].get_tip() for channel in use_channels] - - # Checks - for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Aspirating from a well with a lid is not supported.") - - self._make_sure_channels_exist(use_channels) - for n, p in [ - ("resources", resources), - ("vols", vols), - ("offsets", offsets), - ("flow_rates", flow_rates), - ("liquid_height", liquid_height), - ("blow_out_air_volume", blow_out_air_volume), - ]: - if len(p) != len(use_channels): - raise ValueError( - f"Length of {n} must match length of use_channels: {len(p)} != {len(use_channels)}" - ) - - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - if len(set(resources)) == 1: - resource = resources[0] - resources = [resource] * len(use_channels) - - center_offsets = self._compute_spread_offsets(resource, use_channels, spread) - - # add user defined offsets to the computed centers - offsets = [c + o for c, o in zip(center_offsets, offsets)] - - # create operations - aspirations = [ - SingleChannelAspiration( - resource=r, - volume=v, - offset=o, - flow_rate=fr, - liquid_height=lh, - tip=t, - blow_out_air_volume=bav, - mix=m, - ) - for r, v, o, fr, lh, t, bav, m in zip( - resources, - vols, - offsets, - flow_rates, - liquid_height, - tips, - blow_out_air_volume, - mix or [None] * len(use_channels), # type: ignore - ) - ] - - # queue the operations on the resource (source) and mounted tips (destination) trackers - for op in aspirations: - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - op.resource.tracker.remove_liquid(op.volume) - op.tip.tracker.add_liquid(volume=op.volume) - - extras = self._check_args( - self.backend.aspirate, - backend_kwargs, - default={"ops", "use_channels"}, - strictness=get_strictness(), - ) - for extra in extras: - del backend_kwargs[extra] - - # actually aspirate the liquid - error: Optional[Exception] = None - try: - await self.backend.aspirate(ops=aspirations, use_channels=use_channels, **backend_kwargs) - except Exception as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(aspirations) - if error is not None and isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, aspirations, successes): - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - tip_volume_tracker = self.head[channel].get_tip().tracker - (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() - - if error is not None: - raise error - - @need_setup_finished - async def dispense( - self, - resources: Sequence[Container], - vols: List[float], - use_channels: Optional[List[int]] = None, - flow_rates: Optional[List[Optional[float]]] = None, - offsets: Optional[List[Coordinate]] = None, - liquid_height: Optional[List[Optional[float]]] = None, - blow_out_air_volume: Optional[List[Optional[float]]] = None, - spread: Literal["wide", "tight", "custom"] = "wide", - mix: Optional[List[Mix]] = None, - **backend_kwargs, - ): - """Dispense liquid to the specified channels. - - Examples: - Dispense a constant amount of liquid to the first column: - - >>> await lh.dispense(plate["A1:H1"], 50) - - Dispense an linearly increasing amount of liquid to the first column: - - >>> await lh.dispense(plate["A1:H1"], range(0, 500, 50)) - - Dispense arbitrary amounts of liquid to the first column: - - >>> await lh.dispense(plate["A1:H1"], [0, 40, 10, 50, 100, 200, 300, 400]) - - Dispense liquid to wells in different plates: - - >>> await lh.dispense((plate["A1"], 50), (plate2["A1"], 50), (plate3["A1"], 50)) - - Dispensing with a 10mm z-offset: - - >>> await lh.dispense(plate["A1"], vols=50, offsets=[Coordinate(0, 0, 10)]) - - Dispense a blue bucket (big container), with the first 4 channels (which will be spaced - equally apart): - - >>> await lh.dispense(blue_bucket, vols=50, use_channels=[0, 1, 2, 3]) - - Args: - wells: A list of resources to dispense liquid to. Can be a list of resources, or a single - resource, in which case all channels will dispense to that resource. - vols: A list of volumes to dispense, one for each channel, or a single volume to dispense to - all channels. If `vols` is a single number, then all channels will dispense that volume. In - units of ul. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(channels)` channels will be used. - flow_rates: the flow rates, in ul/s. If `None`, the backend default will be used. - offsets: List of offsets for each channel, a translation that will be applied to the - dispense location. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. - blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the - backend default will be used. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If the dispense info is invalid, in other words, when all channels are `None`. - - ValueError: If all channels are `None`. - """ - - self._log_command( - "dispense", - resources=resources, - vols=vols, - use_channels=use_channels, - flow_rates=flow_rates, - offsets=offsets, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - ) - - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - - self._check_containers(resources) - - use_channels = use_channels or self._default_use_channels or list(range(len(resources))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(use_channels) - flow_rates = flow_rates or [None] * len(use_channels) - liquid_height = liquid_height or [None] * len(use_channels) - blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) - - # Convert everything to floats to handle exotic number types - vols = [float(v) for v in vols] - flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] - liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] - blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] - - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - if len(set(resources)) == 1: - resource = resources[0] - resources = [resource] * len(use_channels) - - center_offsets = self._compute_spread_offsets(resource, use_channels, spread) - - # add user defined offsets to the computed centers - offsets = [c + o for c, o in zip(center_offsets, offsets)] - - tips = [self.head[channel].get_tip() for channel in use_channels] - - # Check the blow out air volume with what was aspirated - if does_volume_tracking(): - if any(bav is not None and bav != 0.0 for bav in blow_out_air_volume): - if self._blow_out_air_volume is None: - raise BlowOutVolumeError("No blowout volume was aspirated.") - for requested_bav, done_bav in zip(blow_out_air_volume, self._blow_out_air_volume): - if requested_bav is not None and done_bav is not None and requested_bav > done_bav: - raise BlowOutVolumeError("Blowout volume is larger than aspirated volume") - - for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Dispensing to plate with lid") - - for n, p in [ - ("resources", resources), - ("vols", vols), - ("offsets", offsets), - ("flow_rates", flow_rates), - ("liquid_height", liquid_height), - ("blow_out_air_volume", blow_out_air_volume), - ]: - if len(p) != len(use_channels): - raise ValueError( - f"Length of {n} must match length of use_channels: {len(p)} != {len(use_channels)}" - ) - - # create operations - dispenses = [ - SingleChannelDispense( - resource=r, - volume=v, - offset=o, - flow_rate=fr, - liquid_height=lh, - tip=t, - blow_out_air_volume=bav, - mix=m, - ) - for r, v, o, fr, lh, t, bav, m in zip( - resources, - vols, - offsets, - flow_rates, - liquid_height, - tips, - blow_out_air_volume, - mix or [None] * len(use_channels), # type: ignore - ) - ] - - # queue the operations on the resource (source) and mounted tips (destination) trackers - for op in dispenses: - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - op.resource.tracker.add_liquid(volume=op.volume) - op.tip.tracker.remove_liquid(op.volume) - - # fix the backend kwargs - extras = self._check_args( - self.backend.dispense, - backend_kwargs, - default={"ops", "use_channels"}, - strictness=get_strictness(), - ) - for extra in extras: - del backend_kwargs[extra] - - # actually dispense the liquid - error: Optional[Exception] = None - try: - await self.backend.dispense(ops=dispenses, use_channels=use_channels, **backend_kwargs) - except Exception as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(dispenses) - if error is not None and isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, dispenses, successes): - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - tip_volume_tracker = self.head[channel].get_tip().tracker - (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() - - if any(bav is not None for bav in blow_out_air_volume): - self._blow_out_air_volume = None - - if error is not None: - raise error - - async def transfer( - self, - source: Well, - targets: List[Well], - source_vol: Optional[float] = None, - ratios: Optional[List[float]] = None, - target_vols: Optional[List[float]] = None, - aspiration_flow_rate: Optional[float] = None, - dispense_flow_rates: Optional[List[Optional[float]]] = None, - **backend_kwargs, - ): - """Transfer liquid from one well to another. - - Examples: - - Transfer 50 uL of liquid from the first well to the second well: - - >>> await lh.transfer(plate["A1"], plate["B1"], source_vol=50) - - Transfer 80 uL of liquid from the first well equally to the first column: - - >>> await lh.transfer(plate["A1"], plate["A1:H1"], source_vol=80) - - Transfer 60 uL of liquid from the first well in a 1:2 ratio to 2 other wells: - - >>> await lh.transfer(plate["A1"], plate["B1:C1"], source_vol=60, ratios=[2, 1]) - - Transfer arbitrary volumes to the first column: - - >>> await lh.transfer(plate["A1"], plate["A1:H1"], target_vols=[3, 1, 4, 1, 5, 9, 6, 2]) - - Args: - source: The source well. - targets: The target wells. - source_vol: The volume to transfer from the source well. - ratios: The ratios to use when transferring liquid to the target wells. If not specified, then - the volumes will be distributed equally. - target_vols: The volumes to transfer to the target wells. If specified, `source_vols` and - `ratios` must be `None`. - aspiration_flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the backend - default will be used. - dispense_flow_rates: The flow rates to use when dispensing, in ul/s. If `None`, the backend - default will be used. Either a single flow rate for all channels, or a list of flow rates, - one for each target well. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - """ - - self._log_command( - "transfer", - source=source, - targets=targets, - source_vol=source_vol, - ratios=ratios, - target_vols=target_vols, - aspiration_flow_rate=aspiration_flow_rate, - dispense_flow_rates=dispense_flow_rates, - ) - - if target_vols is not None: - if ratios is not None: - raise TypeError("Cannot specify ratios and target_vols at the same time") - if source_vol is not None: - raise TypeError("Cannot specify source_vol and target_vols at the same time") - else: - if source_vol is None: - raise TypeError("Must specify either source_vol or target_vols") - - if ratios is None: - ratios = [1] * len(targets) - - target_vols = [source_vol * r / sum(ratios) for r in ratios] - - await self.aspirate( - resources=[source], - vols=[sum(target_vols)], - flow_rates=[aspiration_flow_rate], - **backend_kwargs, - ) - dispense_flow_rates = dispense_flow_rates or [None] * len(targets) - for target, vol, dfr in zip(targets, target_vols, dispense_flow_rates): - await self.dispense( - resources=[target], - vols=[vol], - flow_rates=[dfr], - use_channels=[0], - **backend_kwargs, - ) - - @contextlib.contextmanager - def use_channels(self, channels: List[int]): - """Temporarily use the specified channels as a default argument to `use_channels`. - - Examples: - Use channel index 2 for all liquid handling operations inside the context: - - >>> with lh.use_channels([2]): - ... await lh.pick_up_tips(tip_rack["A1"]) - ... await lh.aspirate(plate["A1"], 50) - ... await lh.dispense(plate["A1"], 50) - - This is equivalent to: - - >>> await lh.pick_up_tips(tip_rack["A1"], use_channels=[2]) - >>> await lh.aspirate(plate["A1"], 50, use_channels=[2]) - >>> await lh.dispense(plate["A1"], 50, use_channels=[2]) - - Within the context manager, you can override the default channels by specifying the - `use_channels` argument explicitly. - """ - - self._default_use_channels = channels - - try: - yield - finally: - self._default_use_channels = None - - @contextlib.asynccontextmanager - async def use_tips( - self, - tip_spots: List[TipSpot], - channels: Optional[List[int]] = None, - discard: bool = True, - ): - """Temporarily pick up tips from the specified tip spots on the specified channels. - - This is a convenience method that picks up tips from `tip_spots` on `channels` when entering - the context, and discards them when exiting the context. When passing `discard=False`, the tips - will be returned instead of discarded. - - Examples: - Use tips from A1 to H1 on channels 0 to 7, then discard: - - >>> with lh.use_tips(tip_rack["A1":"H1"], channels=list(range(8))): - ... await lh.aspirate(plate["A1":"H1"], vols=[50]*8) - ... await lh.dispense(plate["A1":"H1"], vols=[50]*8) - - This is equivalent to: - - >>> await lh.pick_up_tips(tip_rack["A1":"H1"], use_channels=list(range(8))) - >>> await lh.aspirate(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.dispense(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.discard_tips(use_channels=list(range(8))) - - Use tips from A1 to H1 on channels 0 to 7, but return them instead of discarding: - - >>> with lh.use_tips(tip_rack["A1":"H1"], channels=list(range(8)), discard=False): - ... await lh.aspirate(plate["A1":"H1"], vols=[50]*8) - ... await lh.dispense(plate["A1":"H1"], vols=[50]*8) - - This is equivalent to: - - >>> await lh.pick_up_tips(tip_rack["A1":"H1"], use_channels=list(range(8))) - >>> await lh.aspirate(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.dispense(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.return_tips(use_channels=list(range(8))) - """ - - if channels is None: - channels = list(range(len(tip_spots))) - - if len(tip_spots) != len(channels): - raise ValueError("Number of tip spots and channels must match.") - - await self.pick_up_tips(tip_spots, use_channels=channels) - try: - yield - finally: - if discard: - await self.discard_tips(use_channels=channels) - else: - await self.return_tips(use_channels=channels) - - async def pick_up_tips96( - self, - tip_rack: TipRack, - offset: Coordinate = Coordinate.zero(), - **backend_kwargs, - ): - """Pick up tips using the 96 head. This will pick up 96 tips. - - Examples: - Pick up tips from a 96-tip tiprack: - - >>> await lh.pick_up_tips96(my_tiprack) - - Args: - tip_rack: The tip rack to pick up tips from. - offset: Additional offset to use when picking up tips. This is added to - :attr:`default_offset_head96`. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ - - offset = self.default_offset_head96 + offset - - self._log_command( - "pick_up_tips96", - tip_rack=tip_rack, - offset=offset, - ) - - if not isinstance(tip_rack, TipRack): - raise TypeError(f"Resource must be a TipRack, got {tip_rack}") - if not tip_rack.num_items == 96: - raise ValueError("Tip rack must have 96 tips") - - extras = self._check_args( - self.backend.pick_up_tips96, backend_kwargs, default={"pickup"}, strictness=get_strictness() - ) - for extra in extras: - del backend_kwargs[extra] - - # queue operation on all tip trackers - tips: List[Optional[Tip]] = [] - for i, tip_spot in enumerate(tip_rack.get_all_items()): - if not does_tip_tracking() and self.head96[i].has_tip: - self.head96[i].remove_tip() - # only add tips where there is one present. - # it's possible only some tips are present in the tip rack. - if tip_spot.has_tip(): - self.head96[i].add_tip(tip_spot.get_tip(), origin=tip_spot, commit=False) - tips.append(tip_spot.get_tip()) - else: - tips.append(None) - if does_tip_tracking() and not tip_spot.tracker.is_disabled and tip_spot.has_tip(): - tip_spot.tracker.remove_tip() - - pickup_operation = PickupTipRack(resource=tip_rack, offset=offset, tips=tips) - try: - await self.backend.pick_up_tips96(pickup=pickup_operation, **backend_kwargs) - except Exception as error: - for i, tip_spot in enumerate(tip_rack.get_all_items()): - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.rollback() - self.head96[i].rollback() - raise error - else: - for i, tip_spot in enumerate(tip_rack.get_all_items()): - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.commit() - self.head96[i].commit() - - async def drop_tips96( - self, - resource: Union[TipRack, Trash], - offset: Coordinate = Coordinate.zero(), - allow_nonzero_volume: bool = False, - **backend_kwargs, - ): - """Drop tips using the 96 head. This will drop 96 tips. - - Examples: - Drop tips to a 96-tip tiprack: - - >>> await lh.drop_tips96(my_tiprack) - - Drop tips to the trash: - - >>> await lh.drop_tips96(lh.deck.get_trash_area96()) - - Args: - resource: The tip rack to drop tips to. - offset: Additional offset to use when dropping tips. This is added to - :attr:`default_offset_head96`. - allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there - is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero - volume. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ - - offset = self.default_offset_head96 + offset - - self._log_command( - "drop_tips96", - resource=resource, - offset=offset, - allow_nonzero_volume=allow_nonzero_volume, - ) - - if not isinstance(resource, (TipRack, Trash)): - raise TypeError(f"Resource must be a TipRack or Trash, got {resource}") - if isinstance(resource, TipRack) and not resource.num_items == 96: - raise ValueError("Tip rack must have 96 tips") - - extras = self._check_args( - self.backend.drop_tips96, backend_kwargs, default={"drop"}, strictness=get_strictness() - ) - for extra in extras: - del backend_kwargs[extra] - - # queue operation on all tip trackers - for i in range(96): - # it's possible not every channel on this head has a tip. - if not self.head96[i].has_tip: - continue - tip = self.head96[i].get_tip() - if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume and does_volume_tracking(): - error = f"Cannot drop tip with volume {tip.tracker.get_used_volume()} on channel {i}" - raise RuntimeError(error) - if isinstance(resource, TipRack): - tip_spot = resource.get_item(i) - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.add_tip(tip, commit=False) - self.head96[i].remove_tip() - - drop_operation = DropTipRack(resource=resource, offset=offset) - try: - await self.backend.drop_tips96(drop=drop_operation, **backend_kwargs) - except Exception as e: - for i in range(96): - if isinstance(resource, TipRack): - tip_spot = resource.get_item(i) - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.rollback() - self.head96[i].rollback() - raise e - else: - for i in range(96): - if isinstance(resource, TipRack): - tip_spot = resource.get_item(i) - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.commit() - self.head96[i].commit() - - def _get_96_head_origin_tip_rack(self) -> Optional[TipRack]: - """Get the tip rack where the tips on the 96 head were picked up. If no tips were picked up, - return `None`. If different tip racks were found for different tips on the head, raise a - RuntimeError.""" - - tip_spot = self.head96[0].get_tip_origin() - if tip_spot is None: - return None - tip_rack = tip_spot.parent - if tip_rack is None: - # very unlikely, but just in case - raise RuntimeError("No tip rack found for tip") - for i in range(tip_rack.num_items): - other_tip_spot = self.head96[i].get_tip_origin() - if other_tip_spot is None: - raise RuntimeError("Not all channels have a tip origin") - other_tip_rack = other_tip_spot.parent - if tip_rack != other_tip_rack: - raise RuntimeError("All tips must be from the same tip rack") - return tip_rack - - async def return_tips96( - self, - allow_nonzero_volume: bool = False, - offset: Coordinate = Coordinate.zero(), - **backend_kwargs, - ): - """Return the tips on the 96 head to the tip rack where they were picked up. - - Examples: - Return the tips on the 96 head to the tip rack where they were picked up: - - >>> await lh.pick_up_tips96(my_tiprack) - >>> await lh.return_tips96() - - Raises: - RuntimeError: If no tips have been picked up. - """ - - self._log_command( - "return_tips96", - allow_nonzero_volume=allow_nonzero_volume, - ) - - tip_rack = self._get_96_head_origin_tip_rack() - if tip_rack is None: - raise RuntimeError("No tips have been picked up with the 96 head") - return await self.drop_tips96( - tip_rack, - allow_nonzero_volume=allow_nonzero_volume, - offset=offset, - **backend_kwargs, - ) - - async def discard_tips96(self, allow_nonzero_volume: bool = True, **backend_kwargs): - """Permanently discard tips from the 96 head in the trash. This method only works when this - LiquidHandler is configured with a deck that implements the `get_trash_area96` method. - Otherwise, an `ImplementationError` will be raised. - - Examples: - Discard the tips on the 96 head: - - >>> await lh.discard_tips96() - - Args: - allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there - is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero - volume. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - ImplementationError: If the deck does not implement the `get_trash_area96` method. - """ - - self._log_command( - "discard_tips96", - allow_nonzero_volume=allow_nonzero_volume, - ) - - return await self.drop_tips96( - self.deck.get_trash_area96(), - allow_nonzero_volume=allow_nonzero_volume, - **backend_kwargs, - ) - - def _check_96_head_fits_in_container(self, container: Container) -> bool: - """Check if the 96 head can fit in the given container.""" - - tip_width = 2 # approximation - distance_between_tips = 9 - - return ( - container.get_absolute_size_x() >= tip_width + distance_between_tips * 11 - and container.get_absolute_size_y() >= tip_width + distance_between_tips * 7 - ) - - async def aspirate96( - self, - resource: Union[Plate, Container, List[Well]], - volume: float, - offset: Coordinate = Coordinate.zero(), - flow_rate: Optional[float] = None, - liquid_height: Optional[float] = None, - blow_out_air_volume: Optional[float] = None, - mix: Optional[Mix] = None, - **backend_kwargs, - ): - """Aspirate from all wells in a plate or from a container of a sufficient size. - - Examples: - Aspirate an entire 96 well plate or a container of sufficient size: - - >>> await lh.aspirate96(plate, volume=50) - >>> await lh.aspirate96(container, volume=50) - - Args: - resource: Resource object or list of wells. - volume: The volume to aspirate through each channel - offset: Adjustment to where the 96 head should go to aspirate relative to where the plate or container is defined to be. Added to :attr:`default_offset_head96`. Defaults to :func:`Coordinate.zero`. - flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the - backend default will be used. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. If `None`, the backend default will be used. - blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the backend default will be used. - mix: A mix operation to perform after the aspiration, optional. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ - - offset = self.default_offset_head96 + offset - - self._log_command( - "aspirate96", - resource=resource, - volume=volume, - offset=offset, - flow_rate=flow_rate, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - - if not ( - isinstance(resource, (Plate, Container)) - or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) - ): - raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") - - extras = self._check_args( - self.backend.aspirate96, backend_kwargs, default={"aspiration"}, strictness=get_strictness() - ) - for extra in extras: - del backend_kwargs[extra] - - tips = [channel.get_tip() if channel.has_tip else None for channel in self.head96.values()] - aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - - # Convert everything to floats to handle exotic number types - volume = float(volume) - flow_rate = float(flow_rate) if flow_rate is not None else None - blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None - - # Convert Plate to either one Container (single well) or a list of Wells - containers: Sequence[Container] - if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Aspirating from plate with lid") - containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] - elif isinstance(resource, Container): - containers = [resource] - elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): - containers = resource - else: - raise TypeError( - f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " - f" for {resource}" - ) - - if len(containers) == 1: # single container - container = containers[0] - if not self._check_96_head_fits_in_container(container): - raise ValueError("Container too small to accommodate 96 head") - - for tip in tips: - if tip is None: - continue - - if not container.tracker.is_disabled and does_volume_tracking(): - container.tracker.remove_liquid(volume=volume) - tip.tracker.add_liquid(volume=volume) - - aspiration = MultiHeadAspirationContainer( - container=container, - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - else: # multiple containers - # ensure that wells are all in the same plate - plate = containers[0].parent - for well in containers: - if well.parent != plate: - raise ValueError("All wells must be in the same plate") - - if not len(containers) == 96: - raise ValueError(f"aspirate96 expects 96 containers when a list, got {len(containers)}") - - for well, tip in zip(containers, tips): - if tip is None: - continue - - if not well.tracker.is_disabled and does_volume_tracking(): - well.tracker.remove_liquid(volume=volume) - tip.tracker.add_liquid(volume=volume) - - aspiration = MultiHeadAspirationPlate( - wells=cast(List[Well], containers), - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - - try: - await self.backend.aspirate96(aspiration=aspiration, **backend_kwargs) - except Exception: - for tip in tips: - if tip is not None: - tip.tracker.rollback() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.rollback() - raise - else: - for tip in tips: - if tip is not None: - tip.tracker.commit() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.commit() - - async def dispense96( - self, - resource: Union[Plate, Container, List[Well]], - volume: float, - offset: Coordinate = Coordinate.zero(), - flow_rate: Optional[float] = None, - liquid_height: Optional[float] = None, - blow_out_air_volume: Optional[float] = None, - mix: Optional[Mix] = None, - **backend_kwargs, - ): - """Dispense to all wells in a plate. - - Examples: - Dispense an entire 96 well plate: - - >>> await lh.dispense96(plate, volume=50) - - Args: - resource: Resource object or list of wells. - volume: The volume to dispense through each channel - offset: Adjustment to where the 96 head should go to aspirate relative to where the plate or container is defined to be. Added to :attr:`default_offset_head96`. Defaults to :func:`Coordinate.zero`. - flow_rate: The flow rate to use when dispensing, in ul/s. If `None`, the backend default will be used. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. If `None`, the backend default will be used. - blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the backend default will be used. - mix: If provided, the tip will mix after dispensing. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ - - offset = self.default_offset_head96 + offset - - self._log_command( - "dispense96", - resource=resource, - volume=volume, - offset=offset, - flow_rate=flow_rate, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - - if not ( - isinstance(resource, (Plate, Container)) - or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) - ): - raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") - - extras = self._check_args( - self.backend.dispense96, backend_kwargs, default={"dispense"}, strictness=get_strictness() - ) - for extra in extras: - del backend_kwargs[extra] - - tips = [channel.get_tip() if channel.has_tip else None for channel in self.head96.values()] - dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] - - # Convert everything to floats to handle exotic number types - volume = float(volume) - flow_rate = float(flow_rate) if flow_rate is not None else None - blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None - - # Convert Plate to either one Container (single well) or a list of Wells - containers: Sequence[Container] - if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Dispensing to plate with lid is not possible. Remove the lid first.") - containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] - elif isinstance(resource, Container): - containers = [resource] - elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): - containers = resource - else: - raise TypeError( - f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " - f"for {resource}" - ) - - # if we have enough liquid in the tip, remove it from the tip tracker for accounting. - # if we do not (for example because the plunger was up on tip pickup), and we - # do not have volume tracking enabled, we just ignore it. - for tip in tips: - if tip is None: - continue - - if does_volume_tracking(): - tip.tracker.remove_liquid(volume=volume) - elif tip.tracker.get_used_volume() <= volume: - tip.tracker.remove_liquid(volume=min(tip.tracker.get_used_volume(), volume)) - - if len(containers) == 1: # single container - container = containers[0] - if not self._check_96_head_fits_in_container(container): - raise ValueError("Container too small to accommodate 96 head") - - if not container.tracker.is_disabled and does_volume_tracking(): - container.tracker.add_liquid(volume=len([t for t in tips if t is not None]) * volume) - - dispense = MultiHeadDispenseContainer( - container=container, - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - else: - # ensure that wells are all in the same plate - plate = containers[0].parent - for well in containers: - if well.parent != plate: - raise ValueError("All wells must be in the same plate") - - if not len(containers) == 96: - raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") - - for well, tip in zip(containers, tips): - if tip is None: - continue - - if not well.tracker.is_disabled and does_volume_tracking(): - well.tracker.add_liquid(volume=volume) - - dispense = MultiHeadDispensePlate( - wells=cast(List[Well], containers), - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - - try: - await self.backend.dispense96(dispense=dispense, **backend_kwargs) - except Exception: - for tip in tips: - if tip is not None: - tip.tracker.rollback() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.rollback() - raise - else: - for tip in tips: - if tip is not None: - tip.tracker.commit() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.commit() - - async def stamp( - self, - source: Plate, # TODO - target: Plate, - volume: float, - aspiration_flow_rate: Optional[float] = None, - dispense_flow_rate: Optional[float] = None, - ): - """Stamp (aspiration and dispense) one plate onto another. - - Args: - source: the source plate - target: the target plate - volume: the volume to be transported - aspiration_flow_rate: the flow rate for the aspiration, in ul/s. If `None`, the backend - default will be used. - dispense_flow_rate: the flow rate for the dispense, in ul/s. If `None`, the backend default - will be used. - """ - - self._log_command( - "stamp", - source=source, - target=target, - volume=volume, - aspiration_flow_rate=aspiration_flow_rate, - dispense_flow_rate=dispense_flow_rate, - ) - - assert (source.num_items_x, source.num_items_y) == ( - target.num_items_x, - target.num_items_y, - ), "Source and target plates must be the same shape" - - await self.aspirate96(resource=source, volume=volume, flow_rate=aspiration_flow_rate) - await self.dispense96(resource=source, volume=volume, flow_rate=dispense_flow_rate) - - async def pick_up_resource( - self, - resource: Resource, - offset: Coordinate = Coordinate.zero(), - pickup_distance_from_top: Optional[float] = None, - direction: GripDirection = GripDirection.FRONT, - **backend_kwargs, - ): - self._log_command( - "pick_up_resource", - resource=resource, - offset=offset, - pickup_distance_from_top=pickup_distance_from_top, - direction=direction, - ) - - if self.setup_finished and not self._resource_pickups: - raise RuntimeError("No robotic arm is installed on this liquid handler.") - - if pickup_distance_from_top is None: - if resource.preferred_pickup_location is not None: - logger.debug( - f"Using preferred pickup location for resource {resource.name} as pickup_distance_from_top was not specified." - ) - pickup_distance_from_top = resource.get_size_z() - resource.preferred_pickup_location.z - else: - logger.debug( - f"No preferred pickup location for resource {resource.name}. Using default pickup distance of 5mm." - ) - pickup_distance_from_top = 5.0 - - if self._resource_pickup is not None: - raise RuntimeError(f"Resource {self._resource_pickup.resource.name} already picked up") - - self._resource_pickup = ResourcePickup( - resource=resource, - offset=offset, - pickup_distance_from_top=pickup_distance_from_top, - direction=direction, - ) - - extras = self._check_args( - self.backend.pick_up_resource, backend_kwargs, default={"pickup"}, strictness=get_strictness() - ) - for extra in extras: - del backend_kwargs[extra] - - try: - await self.backend.pick_up_resource( - pickup=self._resource_pickup, - **backend_kwargs, - ) - except Exception as e: - self._resource_pickup = None - raise e - - self._state_updated() - - async def move_picked_up_resource( - self, - to: Coordinate, - offset: Coordinate = Coordinate.zero(), - direction: Optional[GripDirection] = None, - **backend_kwargs, - ): - """Move a resource that has been picked up to a new location. - - Args: - to: The new location to move the resource to. (LFB of plate) - offset: The offset to apply to the new location. - direction: The direction in which the resource is gripped. If `None`, the current direction - will be used. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ - - self._log_command( - "move_picked_up_resource", - to=to, - offset=offset, - ) - - if self._resource_pickup is None: - raise RuntimeError("No resource picked up") - await self.backend.move_picked_up_resource( - ResourceMove( - location=to, - resource=self._resource_pickup.resource, - gripped_direction=direction or self._resource_pickup.direction, - pickup_distance_from_top=self._resource_pickup.pickup_distance_from_top, - offset=offset, - ), - **backend_kwargs, - ) - - async def drop_resource( - self, - destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], - offset: Coordinate = Coordinate.zero(), - direction: GripDirection = GripDirection.FRONT, - **backend_kwargs, - ): - self._log_command( - "drop_resource", - destination=destination, - offset=offset, - direction=direction, - ) - - if self._resource_pickup is None: - raise RuntimeError("No resource picked up") - resource = self._resource_pickup.resource - - if isinstance(destination, Resource): - destination.check_can_drop_resource_here(resource) - - # compute rotation based on the pickup_direction and drop_direction - if self._resource_pickup.direction == direction: - rotation_applied_by_move = 0 - if (self._resource_pickup.direction, direction) in ( - (GripDirection.FRONT, GripDirection.RIGHT), - (GripDirection.RIGHT, GripDirection.BACK), - (GripDirection.BACK, GripDirection.LEFT), - (GripDirection.LEFT, GripDirection.FRONT), - ): - rotation_applied_by_move = 90 - if (self._resource_pickup.direction, direction) in ( - (GripDirection.FRONT, GripDirection.BACK), - (GripDirection.BACK, GripDirection.FRONT), - (GripDirection.LEFT, GripDirection.RIGHT), - (GripDirection.RIGHT, GripDirection.LEFT), - ): - rotation_applied_by_move = 180 - if (self._resource_pickup.direction, direction) in ( - (GripDirection.RIGHT, GripDirection.FRONT), - (GripDirection.BACK, GripDirection.RIGHT), - (GripDirection.LEFT, GripDirection.BACK), - (GripDirection.FRONT, GripDirection.LEFT), - ): - rotation_applied_by_move = 270 - - # the resource's absolute rotation should be the resource's previous rotation plus the - # rotation the move applied. The resource's absolute rotation is the rotation of the - # new parent plus the resource's rotation relative to the parent. So to find the new - # rotation of the resource wrt its new parent, we compute what the new absolute rotation - # should be and subtract the rotation of the new parent. - - # moving from a resource from a rotated parent to a non-rotated parent means child inherits/'houses' the rotation after move - resource_absolute_rotation_after_move = ( - resource.get_absolute_rotation().z + rotation_applied_by_move - ) - destination_rotation = ( - destination.get_absolute_rotation().z if not isinstance(destination, Coordinate) else 0 - ) - resource_rotation_wrt_destination = resource_absolute_rotation_after_move - destination_rotation - - # `get_default_child_location`, which is used to compute the translation of the child wrt the parent, - # only considers the child's local rotation. In order to set this new child rotation locally for the - # translation computation, we have to subtract the current rotation of the resource, so we can use - # resource.rotated(z=resource_rotation_wrt_destination_wrt_local) to 'set' the new local rotation. - # Remember, rotated() applies the rotation on top of the current rotation. <- TODO: stupid - resource_rotation_wrt_destination_wrt_local = ( - resource_rotation_wrt_destination - resource.rotation.z - ) - - # get the location of the destination - if isinstance(destination, ResourceStack): - assert destination.direction == "z", ( - "Only ResourceStacks with direction 'z' are currently supported" - ) - - # the resource can be rotated wrt the ResourceStack. This is allowed as long - # as it's in multiples of 180 degrees. 90 degrees is not allowed. - if resource_rotation_wrt_destination % 180 != 0: - raise ValueError( - "Resource rotation wrt ResourceStack must be a multiple of 180 degrees, " - f"got {resource_rotation_wrt_destination} degrees" - ) - - to_location = destination.get_location_wrt(self.deck) + destination.get_new_child_location( - resource.rotated(z=resource_rotation_wrt_destination_wrt_local) - ).rotated(destination.get_absolute_rotation()) - elif isinstance(destination, Coordinate): - to_location = destination - elif isinstance(destination, ResourceHolder): - if destination.resource is not None and destination.resource is not resource: - raise RuntimeError("Destination already has a plate") - child_wrt_parent = destination.get_default_child_location( - resource.rotated(z=resource_rotation_wrt_destination_wrt_local) - ).rotated(destination.get_absolute_rotation()) - to_location = destination.get_location_wrt(self.deck) + child_wrt_parent - elif isinstance(destination, PlateAdapter): - if not isinstance(resource, Plate): - raise ValueError("Only plates can be moved to a PlateAdapter") - # Calculate location adjustment of Plate based on PlateAdapter geometry - adjusted_plate_anchor = destination.compute_plate_location( - resource.rotated(z=resource_rotation_wrt_destination_wrt_local) - ).rotated(destination.get_absolute_rotation()) - to_location = destination.get_location_wrt(self.deck) + adjusted_plate_anchor - elif isinstance(destination, Plate) and isinstance(resource, Lid): - lid = resource - plate_location = destination.get_location_wrt(self.deck) - child_wrt_parent = destination.get_lid_location( - lid.rotated(z=resource_rotation_wrt_destination_wrt_local) - ).rotated(destination.get_absolute_rotation()) - to_location = plate_location + child_wrt_parent - else: - to_location = destination.get_location_wrt(self.deck) - - drop = ResourceDrop( - resource=self._resource_pickup.resource, - destination=to_location, - destination_absolute_rotation=destination.get_absolute_rotation() - if isinstance(destination, Resource) - else Rotation(0, 0, 0), - offset=offset, - pickup_distance_from_top=self._resource_pickup.pickup_distance_from_top, - pickup_direction=self._resource_pickup.direction, - direction=direction, - rotation=rotation_applied_by_move, - ) - result = await self.backend.drop_resource(drop=drop, **backend_kwargs) - - self._resource_pickup = None - self._state_updated() - - # we rotate the resource on top of its original rotation. So in order to set the new rotation, - # we have to subtract its current rotation. - resource.rotate(z=resource_rotation_wrt_destination - resource.rotation.z) - - # assign to destination - resource.unassign() - if isinstance(destination, Coordinate): - to_location -= self.deck.location # passed as an absolute location, but stored as relative - self.deck.assign_child_resource(resource, location=to_location) - elif isinstance(destination, PlateHolder): # .zero() resources - destination.assign_child_resource(resource) - elif isinstance(destination, ResourceHolder): # .zero() resources - destination.assign_child_resource(resource) - elif isinstance(destination, (ResourceStack, PlateReader)): # manage its own resources - if isinstance(destination, ResourceStack) and destination.direction != "z": - raise ValueError("Only ResourceStacks with direction 'z' are currently supported") - destination.assign_child_resource(resource) - elif isinstance(destination, Tilter): - destination.assign_child_resource(resource, location=destination.child_location) - elif isinstance(destination, PlateAdapter): - if not isinstance(resource, Plate): - raise ValueError("Only plates can be moved to a PlateAdapter") - destination.assign_child_resource( - resource, location=destination.compute_plate_location(resource) - ) - elif isinstance(destination, Plate) and isinstance(resource, Lid): - destination.assign_child_resource(resource) - elif isinstance(destination, Trash): - pass # don't assign to trash, resource will simply be unassigned - else: - destination.assign_child_resource(resource, location=to_location) - - return result - - async def move_resource( - self, - resource: Resource, - to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], - intermediate_locations: Optional[List[Coordinate]] = None, - pickup_offset: Coordinate = Coordinate.zero(), - destination_offset: Coordinate = Coordinate.zero(), - pickup_distance_from_top: float = 0, - pickup_direction: GripDirection = GripDirection.FRONT, - drop_direction: GripDirection = GripDirection.FRONT, - **backend_kwargs, - ): - """Move a resource to a new location. - - Has convenience methods :meth:`move_plate` and :meth:`move_lid`. - - Examples: - Move a plate to a new location: - - >>> await lh.move_resource(plate, to=Coordinate(100, 100, 100)) - - Args: - resource: The Resource object. - to: The absolute coordinate (meaning relative to deck) to move the resource to. - intermediate_locations: A list of intermediate locations to move the resource through. - pickup_offset: The offset from the resource's origin, optional (rarely necessary). - destination_offset: The offset from the location's origin, optional (rarely necessary). - pickup_distance_from_top: The distance from the top of the resource to pick up from. - pickup_direction: The direction from which to pick up the resource. - drop_direction: The direction from which to put down the resource. - """ - - self._log_command( - "move_resource", - resource=resource, - to=to, - intermediate_locations=intermediate_locations, - pickup_offset=pickup_offset, - destination_offset=destination_offset, - pickup_distance_from_top=pickup_distance_from_top, - pickup_direction=pickup_direction, - drop_direction=drop_direction, - ) - - extra = self._check_args( - self.backend.pick_up_resource, - backend_kwargs, - default={"pickup"}, - strictness=Strictness.IGNORE, - ) - pickup_kwargs = {k: v for k, v in backend_kwargs.items() if k not in extra} - - await self.pick_up_resource( - resource=resource, - offset=pickup_offset, - pickup_distance_from_top=pickup_distance_from_top, - direction=pickup_direction, - **pickup_kwargs, - ) - - for intermediate_location in intermediate_locations or []: - await self.move_picked_up_resource(to=intermediate_location) - - extra = self._check_args( - self.backend.drop_resource, - backend_kwargs, - default={"drop"}, - strictness=Strictness.IGNORE, - ) - drop_kwargs = {k: v for k, v in backend_kwargs.items() if k not in extra} - - await self.drop_resource( - destination=to, - offset=destination_offset, - direction=drop_direction, - **drop_kwargs, - ) - - async def move_lid( - self, - lid: Lid, - to: Union[Plate, ResourceStack, Coordinate], - intermediate_locations: Optional[List[Coordinate]] = None, - pickup_offset: Coordinate = Coordinate.zero(), - destination_offset: Coordinate = Coordinate.zero(), - pickup_direction: GripDirection = GripDirection.FRONT, - drop_direction: GripDirection = GripDirection.FRONT, - pickup_distance_from_top: float = 5.7 - 3.33, - **backend_kwargs, - ): - """Move a lid to a new location. - - A convenience method for :meth:`move_resource`. - - Examples: - Move a lid to the :class:`~resources.ResourceStack`: - - >>> await lh.move_lid(plate.lid, stacking_area) - - Move a lid to the stacking area and back, grabbing it from the left side: - - >>> await lh.move_lid(plate.lid, stacking_area, pickup_direction=GripDirection.LEFT) - >>> await lh.move_lid(stacking_area.get_top_item(), plate, drop_direction=GripDirection.LEFT) - - Args: - lid: The lid to move. Can be either a Plate object or a Lid object. - to: The location to move the lid to, either a plate, ResourceStack or a Coordinate. - pickup_offset: The offset from the resource's origin, optional (rarely necessary). - destination_offset: The offset from the location's origin, optional (rarely necessary). - - Raises: - ValueError: If the lid is not assigned to a resource. - """ - - self._log_command( - "move_lid", - lid=lid, - to=to, - intermediate_locations=intermediate_locations, - pickup_offset=pickup_offset, - destination_offset=destination_offset, - pickup_direction=pickup_direction, - drop_direction=drop_direction, - pickup_distance_from_top=pickup_distance_from_top, - ) - - await self.move_resource( - lid, - to=to, - intermediate_locations=intermediate_locations, - pickup_distance_from_top=pickup_distance_from_top, - pickup_offset=pickup_offset, - destination_offset=destination_offset, - pickup_direction=pickup_direction, - drop_direction=drop_direction, - **backend_kwargs, - ) - - async def move_plate( - self, - plate: Plate, - to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], - intermediate_locations: Optional[List[Coordinate]] = None, - pickup_offset: Coordinate = Coordinate.zero(), - destination_offset: Coordinate = Coordinate.zero(), - drop_direction: GripDirection = GripDirection.FRONT, - pickup_direction: GripDirection = GripDirection.FRONT, - pickup_distance_from_top: float = 13.2 - 3.33, - **backend_kwargs, - ): - """Move a plate to a new location. - - A convenience method for :meth:`move_resource`. - - Examples: - Move a plate to into a carrier spot: - - >>> await lh.move_plate(plate, plt_car[1]) - - Move a plate to an absolute location: - - >>> await lh.move_plate(plate_01, Coordinate(100, 100, 100)) - - Move a lid to another carrier spot, grabbing it from the left side: - - >>> await lh.move_plate(plate, plt_car[1], pickup_direction=GripDirection.LEFT) - >>> await lh.move_plate(plate, plt_car[0], drop_direction=GripDirection.LEFT) - - Move a resource while visiting a few intermediate locations along the way: - - >>> await lh.move_plate(plate, plt_car[1], intermediate_locations=[ - ... Coordinate(100, 100, 100), - ... Coordinate(200, 200, 200), - ... ]) - - Args: - plate: The plate to move. Can be either a Plate object or a ResourceHolder object. - to: The location to move the plate to, either a plate, ResourceHolder or a Coordinate. - pickup_offset: The offset from the resource's origin, optional (rarely necessary). - destination_offset: The offset from the location's origin, optional (rarely necessary). - """ - - self._log_command( - "move_plate", - plate=plate, - to=to, - intermediate_locations=intermediate_locations, - pickup_offset=pickup_offset, - destination_offset=destination_offset, - pickup_direction=pickup_direction, - drop_direction=drop_direction, - pickup_distance_from_top=pickup_distance_from_top, - ) - - await self.move_resource( - plate, - to=to, - intermediate_locations=intermediate_locations, - pickup_distance_from_top=pickup_distance_from_top, - pickup_offset=pickup_offset, - destination_offset=destination_offset, - pickup_direction=pickup_direction, - drop_direction=drop_direction, - **backend_kwargs, - ) - - def serialize(self) -> dict: - return { - **Resource.serialize(self), - **Machine.serialize(self), - "default_offset_head96": serialize(self.default_offset_head96), - } - - @classmethod - def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler: - """Deserialize a liquid handler from a dictionary. - - Args: - data: A dictionary representation of the liquid handler. - """ - - deck_data = data["children"][0] - deck = Deck.deserialize(data=deck_data, allow_marshal=allow_marshal) - backend = LiquidHandlerBackend.deserialize(data=data["backend"]) - - if "default_offset_head96" in data: - default_offset = deserialize(data["default_offset_head96"], allow_marshal=allow_marshal) - assert isinstance(default_offset, Coordinate) - else: - default_offset = Coordinate.zero() - - return cls( - deck=deck, - backend=backend, - default_offset_head96=default_offset, - ) - - @classmethod - def load(cls, path: str) -> LiquidHandler: - """Load a liquid handler from a file. - - Args: - path: The path to the file to load from. - """ - - with open(path, "r", encoding="utf-8") as f: - return cls.deserialize(json.load(f)) - - async def prepare_for_manual_channel_operation(self, channel: int): - self._log_command( - "prepare_for_manual_channel_operation", - channel=channel, - ) - - assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" - await self.backend.prepare_for_manual_channel_operation(channel=channel) - - async def move_channel_x(self, channel: int, x: float): - """Move channel to absolute x position""" - self._log_command("move_channel_x", channel=channel, x=x) - assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" - await self.backend.move_channel_x(channel=channel, x=x) - - async def move_channel_y(self, channel: int, y: float): - """Move channel to absolute y position""" - self._log_command("move_channel_y", channel=channel, y=y) - assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" - await self.backend.move_channel_y(channel=channel, y=y) - - async def move_channel_z(self, channel: int, z: float): - """Move channel to absolute z position""" - self._log_command("move_channel_z", channel=channel, z=z) - assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" - await self.backend.move_channel_z(channel=channel, z=z) - - # -- Resource methods -- - - def assign_child_resource( - self, - resource: Resource, - location: Optional[Coordinate], - reassign: bool = True, - ): - """Not implement on LiquidHandler, since the deck is managed by the :attr:`deck` attribute.""" - raise NotImplementedError( - "Cannot assign child resource to liquid handler. Use lh.deck.assign_child_resource() instead." - ) - - async def probe_tip_presence_via_pickup( - self, tip_spots: List[TipSpot], use_channels: Optional[List[int]] = None - ) -> Dict[str, bool]: - """Probe tip presence by attempting pickup on each TipSpot. - - Args: - tip_spots: TipSpots to probe. - use_channels: Channels to use (must match tip_spots length). - - Returns: - Dict[str, bool]: Mapping of tip spot names to presence flags. - """ - - if use_channels is None: - use_channels = list(range(len(tip_spots))) - - if len(use_channels) > self.backend.num_channels: - raise ValueError( - "Liquid handler given more channels to use than exist: " - f"Given {len(use_channels)} channels to use but liquid handler " - f"only has {self.backend.num_channels}." - ) - - if len(use_channels) != len(tip_spots): - raise ValueError( - f"Length mismatch: received {len(use_channels)} channels for " - f"{len(tip_spots)} tip spots. One channel must be assigned per tip spot." - ) - - presence_flags = [True] * len(tip_spots) - z_height = tip_spots[0].get_location_wrt(self.deck, z="top").z + 5 - - # Step 1: Cluster tip spots by x-coordinate - clusters_by_x: Dict[float, List[Tuple[TipSpot, int, int]]] = {} - for idx, tip_spot in enumerate(tip_spots): - assert tip_spot.location is not None, "TipSpot location must be at a location" - x = tip_spot.location.x - clusters_by_x.setdefault(x, []).append((tip_spot, use_channels[idx], idx)) - - sorted_clusters = [clusters_by_x[x] for x in sorted(clusters_by_x)] - - # Step 2: Probe each cluster - for cluster in sorted_clusters: - tip_subset, channel_subset, index_subset = zip(*cluster) - - try: - await self.pick_up_tips( - list(tip_subset), - use_channels=list(channel_subset), - minimum_traverse_height_at_beginning_of_a_command=z_height, - z_position_at_end_of_a_command=z_height, - ) - except ChannelizedError as e: - for ch in e.errors: - if ch in channel_subset: - failed_local_idx = channel_subset.index(ch) - presence_flags[index_subset[failed_local_idx]] = False - else: - raise - - # Step 3: Drop tips immediately after probing - if any(presence_flags[index] for index in index_subset): - spots = [ts for ts, _, i in cluster if presence_flags[i]] - use_channels = [uc for _, uc, i in cluster if presence_flags[i]] - try: - await self.drop_tips( - spots, - use_channels=use_channels, - # minimum_traverse_height_at_beginning_of_a_command=z_height, - z_position_at_end_of_a_command=z_height, - ) - except Exception as e: - assert cluster[0][0].location is not None, "TipSpot location must be at a location" - print(f"Warning: drop_tips failed for cluster at x={cluster[0][0].location.x}: {e}") - - return {ts.name: flag for ts, flag in zip(tip_spots, presence_flags)} - - async def probe_tip_inventory( - self, - tip_spots: List[TipSpot], - probing_fn: Optional[TipPresenceProbingMethod] = None, - use_channels: Optional[List[int]] = None, - ) -> Dict[str, bool]: - """Probe the presence of tips in multiple tip spots. - - The provided ``probing_fn`` is used for probing batches of tip spots. The - default uses :meth:`probe_tip_presence_via_pickup`. - - Examples: - Probe all tip spots in one or more tip racks. - - >>> import pylabrobot.resources.functional as F - >>> spots = F.get_all_tip_spots([tip_rack_1, tip_rack_2]) - >>> presence = await lh.probe_tip_inventory(spots) - - Args: - tip_spots: - Tip spots to probe for presence of a tip. - probing_fn: - Function used to probe a batch of tip spots. Must accept ``tip_spots`` and - ``use_channels`` and return a mapping of tip spot names to boolean flags. - - Returns: - Mapping from tip spot names to whether a tip is present. - """ - - if probing_fn is None: - probing_fn = self.probe_tip_presence_via_pickup - - results: Dict[str, bool] = {} - - if use_channels is None: - use_channels = list(range(self.backend.num_channels)) - num_channels = len(use_channels) - - for i in range(0, len(tip_spots), num_channels): - subset = tip_spots[i : i + num_channels] - use_channels = use_channels[: len(subset)] - batch_result = await probing_fn(subset, use_channels) - results.update(batch_result) - - return results - - async def consolidate_tip_inventory( - self, tip_racks: List[TipRack], use_channels: Optional[List[int]] = None - ): - """ - Consolidate partial tip racks on the deck by redistributing tips. - - This function identifies partially-filled tip racks (excluding any in - `ignore_tiprack_list`) in the 'tip_inventory`, the subset of the deck tree - that is of type TipRack, and consolidates their tips into as few tip racks - as possible, grouped by tip model. - Tips are moved efficiently to minimize pipetting steps, avoiding redundant - visits to the same drop columns. - - Args: - tip_racks: List of TipRack objects to consolidate. - use_channels: Optional list of channels to use for consolidation. If not - provided, the first 8 available channels will be used. - """ - - def merge_sublists(lists: List[List[TipSpot]], max_len: int) -> List[List[TipSpot]]: - """Merge adjacent sublists if combined length <= max_len, without splitting sublists.""" - merged: List[List[TipSpot]] = [] - buffer: List[TipSpot] = [] - - for sublist in lists: - if len(sublist) == 0: - continue # skip empty sublists - - if len(buffer) + len(sublist) <= max_len: - buffer.extend(sublist) - else: - if buffer: - merged.append(buffer) - buffer = sublist # start new buffer - - if len(buffer) > 0: - merged.append(buffer) - - return merged - - def divide_list_into_chunks( - list_l: List[TipSpot], chunk_size: int - ) -> Generator[List[TipSpot], None, None]: - """Divides a list into smaller chunks of a specified size. - - Parameters: - - list_l: The list to be divided into chunks. - - chunk_size: The size of each chunk. - - Returns: - A generator that yields chunks of the list. - """ - for i in range(0, len(list_l), chunk_size): - yield list_l[i : i + chunk_size] - - clusters_by_model: Dict[int, List[Tuple[TipRack, int]]] = {} - - for idx, tip_rack in enumerate(tip_racks): - # Only consider partially-filled tip_racks - tip_status = [tip_spot.tracker.has_tip for tip_spot in tip_rack.get_all_items()] - - if not (any(tip_status) and not all(tip_status)): - continue # ignore non-partially-filled tip_racks - - tipspots_w_tips = [ - tip_spot for has_tip, tip_spot in zip(tip_status, tip_rack.get_all_items()) if has_tip - ] - - # Identify model by hashed unique physical characteristics - current_model = hash(tipspots_w_tips[0].tracker.get_tip()) - if not all( - hash(tip_spot.tracker.get_tip()) == current_model for tip_spot in tipspots_w_tips[1:] - ): - raise ValueError( - f"Tip rack {tip_rack.name} has mixed tip models, cannot consolidate: " - f"{[tip_spot.tracker.get_tip() for tip_spot in tipspots_w_tips]}" - ) - - num_empty_tipspots = len(tip_status) - len(tipspots_w_tips) - clusters_by_model.setdefault(current_model, []).append((tip_rack, num_empty_tipspots)) - - # Sort partially-filled tipracks from most to least empty - for model, rack_list in clusters_by_model.items(): - rack_list.sort(key=lambda x: x[1]) - - # Consolidate one tip model at a time across all tip_racks of that model - for model, rack_list in clusters_by_model.items(): - print(f"Consolidating: - {', '.join([rack.name for rack, _ in rack_list])}") - - all_tip_spots_list = [ - tip_spot for tip_rack, _ in rack_list for tip_spot in tip_rack.get_all_items() - ] - - # 1: Record current tip state - current_tip_presence_list = [tip_spot.has_tip() for tip_spot in all_tip_spots_list] - - # 2: Generate target/consolidated tip state - total_length = len(all_tip_spots_list) - num_tips_per_model = sum(current_tip_presence_list) - - target_tip_presence_list = [i < num_tips_per_model for i in range(total_length)] - - # 3: Calculate tip_spots involved in tip movement - tip_movement_list = [ - c - t for c, t in zip(current_tip_presence_list, target_tip_presence_list) - ] - - tip_origin_indices = [i for i, v in enumerate(tip_movement_list) if v == 1] - all_origin_tip_spots = [all_tip_spots_list[idx] for idx in tip_origin_indices] - - tip_target_indices = [i for i, v in enumerate(tip_movement_list) if v == -1] - all_target_tip_spots = [all_tip_spots_list[idx] for idx in tip_target_indices] - - # Only continue if tip_racks are not already consolidated - if len(all_target_tip_spots) == 0: - print("Tips already optimally consolidated!") - continue - - # 4: Cluster target tip_spots by BOTH parent tip_rack & x-coordinate - def key_for_tip_spot(tip_spot: TipSpot) -> Tuple[str, float]: - """Key function to sort tip spots by parent name and x-coordinate.""" - assert tip_spot.parent is not None and tip_spot.location is not None - return (tip_spot.parent.name, round(tip_spot.location.x, 3)) - - sorted_tip_spots = sorted(all_target_tip_spots, key=key_for_tip_spot) - - target_tip_clusters_by_parent_x: Dict[Tuple[str, float], List[TipSpot]] = {} - - for tip_spot in sorted_tip_spots: - key = key_for_tip_spot(tip_spot) - if key not in target_tip_clusters_by_parent_x: - target_tip_clusters_by_parent_x[key] = [] - target_tip_clusters_by_parent_x[key].append(tip_spot) - - current_tip_model = all_origin_tip_spots[0].tracker.get_tip() - - # Ensure there are channels that can pick up the tip model - if use_channels is None: - num_channels_available = len( - [ - c - for c in range(self.backend.num_channels) - if self.backend.can_pick_up_tip(c, current_tip_model) - ] - ) - use_channels = list(range(num_channels_available)) - num_channels_available = len(use_channels) - - # 5: Optimize speed - if num_channels_available == 0: - raise ValueError(f"No channel capable of handling tips on deck: {current_tip_model}") - - # by aggregating drop columns i.e. same drop column should not be visited twice! - if num_channels_available >= 8: # physical constraint of tip_rack's having 8 rows - merged_target_tip_clusters = merge_sublists( - list(target_tip_clusters_by_parent_x.values()), max_len=8 - ) - else: # by chunking drop tip_spots list into size of available channels - merged_target_tip_clusters = list( - divide_list_into_chunks(all_target_tip_spots, chunk_size=num_channels_available) - ) - - len_transfers = len(merged_target_tip_clusters) - - # 6: Execute tip movement/consolidation - for idx, target_tip_spots in enumerate(merged_target_tip_clusters): - print(f" - tip transfer cycle: {idx + 1} / {len_transfers}") - - origin_tip_spots = [all_origin_tip_spots.pop(0) for _ in range(len(target_tip_spots))] - - these_channels = use_channels[: len(target_tip_spots)] - await self.pick_up_tips(origin_tip_spots, use_channels=these_channels) - await self.drop_tips(target_tip_spots, use_channels=these_channels) diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index bc0e9580a50..e0b4b950990 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -1,169 +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: - volume: float - repetitions: int - flow_rate: float - - -@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..3a05701449c --- /dev/null +++ b/pylabrobot/mettler_toledo/__init__.py @@ -0,0 +1,5 @@ +from .mettler_toledo import ( + MettlerToledoError, + MettlerToledoWXS205SDUDriver, + MettlerToledoWXS205SDUScaleBackend, +) diff --git a/pylabrobot/scales/mettler_toledo_backend.py b/pylabrobot/mettler_toledo/mettler_toledo.py similarity index 78% rename from pylabrobot/scales/mettler_toledo_backend.py rename to pylabrobot/mettler_toledo/mettler_toledo.py index ae73eb114df..9957aff4a69 100644 --- a/pylabrobot/scales/mettler_toledo_backend.py +++ b/pylabrobot/mettler_toledo/mettler_toledo.py @@ -3,13 +3,14 @@ import asyncio import logging import time -import warnings from typing import List, Literal, Optional, Union +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.weighing import ScaleBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial -from pylabrobot.scales.scale_backend import ScaleBackend -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) class MettlerToledoError(Exception): @@ -143,10 +144,11 @@ def adjustment_needed(from_terminal: bool) -> "MettlerToledoError": MettlerToledoResponse = List[str] -class MettlerToledoWXS205SDUBackend(ScaleBackend): - """Backend for the Mettler Toledo WXS205SDU scale. +class MettlerToledoWXS205SDUDriver(Driver): + """Driver for the Mettler Toledo WXS205SDU scale. - This scale is used by Hamilton in the liquid verification kit (LVK). + 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 @@ -158,13 +160,10 @@ class MettlerToledoWXS205SDUBackend(ScaleBackend): command processing or ignores entire commands." """ - # === Constructor === - def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): super().__init__() - self.io = Serial( - human_readable_device_name="Mettler Toledo Scale", + human_readable_device_name="Mettler Toledo WXS205SDU", port=port, vid=vid, pid=pid, @@ -172,16 +171,9 @@ def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6 timeout=1, ) - async def setup(self) -> None: - # Core state + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() - - # set output unit to grams - await self.send_command("M21 0 0") - - # Handshake: parse requested serial number - self.serial_number = await self.request_serial_number() - # TODO: verify serial number pattern + logger.info("[MettlerToledo %s] connected", self.io.port) async def stop(self) -> None: await self.io.stop() @@ -283,11 +275,41 @@ async def send_command(self, command: str, timeout: int = 60) -> MettlerToledoRe # 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") + + +class MettlerToledoWXS205SDUScaleBackend(ScaleBackend): + """Translates ScaleBackend interface into driver commands for the WXS205SDU. + + Protocol encoding (building MT-SICS command strings) lives here. + """ + + def __init__(self, driver: MettlerToledoWXS205SDUDriver): + self.driver = driver + self.serial_number: Optional[str] = None + + async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: + """Initialize scale after driver connects: set output unit to grams and read serial.""" + await self.driver.send_command("M21 0 0") + self.serial_number = await self.request_serial_number() + logger.info( + "[MettlerToledo %s] initialized: serial_number=%s", self.driver.io.port, self.serial_number + ) + # === 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") + response = await self.driver.send_command("I4") serial_number = response[2] serial_number = serial_number.replace('"', "") return serial_number @@ -296,18 +318,18 @@ async def request_serial_number(self) -> str: async def zero_immediately(self) -> MettlerToledoResponse: """Zero the scale immediately. (ACTION command)""" - return await self.send_command("ZI") + return await self.driver.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") + return await self.driver.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}") + return await self.driver.send_command(f"ZC {timeout}") async def zero( self, timeout: Union[Literal["stable"], float, int] = "stable" @@ -321,35 +343,35 @@ async def zero( """ if timeout == "stable": - return await self.zero_stable() - - if not isinstance(timeout, (float, int)): + result = await self.zero_stable() + elif not isinstance(timeout, (float, int)): raise TypeError("timeout must be a float or 'stable'") - - if timeout < 0: + 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) - if timeout == 0: - return await self.zero_immediately() - - return 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") + return await self.driver.send_command("T") async def tare_immediately(self) -> MettlerToledoResponse: """Tare the scale immediately. (ACTION command)""" - return await self.send_command("TI") + return await self.driver.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}") + return await self.driver.send_command(f"TC {timeout}") async def tare( self, timeout: Union[Literal["stable"], float, int] = "stable" @@ -364,17 +386,18 @@ async def tare( if timeout == "stable": # "Use T to tare the balance. The next stable weight value will be saved in the tare memory." - return await self.tare_stable() - - if not isinstance(timeout, (float, int)): + result = await self.tare_stable() + elif not isinstance(timeout, (float, int)): raise TypeError("timeout must be a float or 'stable'") - - if timeout < 0: + 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) - if timeout == 0: - return await self.tare_immediately() - return await self.tare_timeout(timeout) + logger.info("[MettlerToledo %s] tared: timeout=%s", self.serial_number, timeout) + return result # # Weight reading commands # # @@ -383,7 +406,7 @@ async def request_tare_weight(self) -> float: "Use TA to query the current tare value or preset a known tare value." """ - response = await self.send_command("TA") + response = await self.driver.send_command("TA") tare = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -391,7 +414,7 @@ async def request_tare_weight(self) -> float: async def clear_tare(self) -> MettlerToledoResponse: """TAC - Clear tare weight value (MEM-WRITE command)""" - return await self.send_command("TAC") + return await self.driver.send_command("TAC") async def read_stable_weight(self) -> float: """Read a stable weight value from the scale. (MEASUREMENT command) @@ -404,10 +427,11 @@ async def read_stable_weight(self) -> float: doors to achieve a stable weight." """ - response = await self.send_command("S") + response = await self.driver.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: @@ -420,10 +444,11 @@ async def read_dynamic_weight(self, timeout: float) -> float: timeout = int(timeout * 1000) # convert to milliseconds - response = await self.send_command(f"SC {timeout}") + response = await self.driver.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: @@ -433,9 +458,10 @@ async def read_weight_value_immediately(self) -> float: balance to the connected communication partner via the interface." """ - response = await self.send_command("SI") + response = await self.driver.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: @@ -460,88 +486,3 @@ async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "sta return await self.read_weight_value_immediately() return await self.read_dynamic_weight(timeout) - - # Commands for (optional) display manipulation - - 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") - - # # # Deprecated alias with warning # # # - - # # TODO: remove 2026-03 (giving people >2 months to update) - - async def get_serial_number(self) -> str: - """Deprecated: Use request_serial_number() instead.""" - warnings.warn( - "get_serial_number() is deprecated and will be removed in 2026-03. " - "Use request_serial_number() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.request_serial_number() - - async def get_tare_weight(self) -> float: - """Deprecated: Use request_tare_weight() instead.""" - warnings.warn( - "get_tare_weight() is deprecated and will be removed in 2026-03. " - "Use request_tare_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.request_tare_weight() - - async def get_stable_weight(self) -> float: - """Deprecated: Use read_stable_weight() instead.""" - warnings.warn( - "get_stable_weight() is deprecated and will be removed in 2026-03. " - "Use read_stable_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_stable_weight() - - async def get_dynamic_weight(self, timeout: float) -> float: - """Deprecated: Use read_dynamic_weight() instead.""" - warnings.warn( - "get_dynamic_weight() is deprecated and will be removed in 2026-03. " - "Use read_dynamic_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_dynamic_weight(timeout) - - async def get_weight_value_immediately(self) -> float: - """Deprecated: Use read_weight_value_immediately() instead.""" - warnings.warn( - "get_weight_value_immediately() is deprecated and will be removed in 2026-03. " - "Use read_weight_value_immediately() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_weight_value_immediately() - - async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: - """Deprecated: Use read_weight() instead.""" - warnings.warn( - "get_weight() is deprecated and will be removed in 2026-03. Use read_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_weight(timeout) - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class MettlerToledoWXS205SDU: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`MettlerToledoWXS205SDU` is deprecated. Please use `MettlerToledoWXS205SDUBackend` instead." - ) 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..3c60879b988 --- /dev/null +++ b/pylabrobot/molecular_devices/__init__.py @@ -0,0 +1,12 @@ +from .imageXpress.pico.backend import PicoDriver, PicoMicroscopyBackend +from .imageXpress.pico.pico import Pico +from .spectramax import ( + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesTemperatureBackend, + SpectraMax384Plus, + SpectraMax384PlusAbsorbanceBackend, + SpectraMaxM5, + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/__init__.py b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py new file mode 100644 index 00000000000..09f61b106b8 --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py @@ -0,0 +1,2 @@ +from .backend import PicoDriver, PicoMicroscopyBackend +from .pico import Pico diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py similarity index 79% rename from pylabrobot/microscopes/molecular_devices/pico/backend.py rename to pylabrobot/molecular_devices/imageXpress/pico/backend.py index 9b5cfacdc5b..65c928bc0a1 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -9,6 +9,17 @@ from collections import defaultdict from typing import Callable, Dict, List, Optional, Tuple, TypeVar +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.microscopy import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + MicroscopyBackend, + Objective, +) +from pylabrobot.device import Driver from pylabrobot.io.sila.grpc import ( command_execution_uuid, decode_command_confirmation, @@ -24,18 +35,10 @@ unlock_server_params, varint_as_signed, ) -from pylabrobot.plate_reading.backend import ImagerBackend -from pylabrobot.plate_reading.standard 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 +from pylabrobot.serializer import SerializableMixin try: import grpc # type: ignore[import-untyped] @@ -114,11 +117,7 @@ def _extract_integer(field_num: int) -> int: def _labware_params_from_plate(plate: Plate) -> dict: - """Derive Pico labware JSON from a PLR :class:`Plate`. - - Inspects well positions and geometry so the caller doesn't have to supply a - hand-crafted 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 @@ -131,8 +130,6 @@ def _labware_params_from_plate(plate: Plate) -> dict: well_size_x = well_a1.get_size_x() well_size_y = well_a1.get_size_y() - # PLR locations are Left-Front-Bottom of the well bounding box; Pico - # dist2first* are measured to the well center. dist2firstcol = a1_loc.x + well_size_x / 2.0 last_row_label = row_index_to_label(nrows - 1) @@ -310,7 +307,7 @@ def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): # Mapping from Objective enum to Pico objective ID strings _OBJECTIVE_MAP: Dict[Objective, str] = { - Objective.O_2_5X_N_PLAN: "N PLAN 2.5x/0.07", # Pico + 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", @@ -318,24 +315,17 @@ def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): } -class ExperimentalPicoBackend(ImagerBackend): - """Backend for Molecular Devices ImageXpress Pico automated microscope. +class PicoDriver(Driver): + """gRPC/SiLA 2 driver for the Molecular Devices ImageXpress Pico. - Communicates with the instrument via SiLA 2 over gRPC. All services (imaging, - door control, instrument control) run on a single port and share one lock. + Owns the gRPC channel, lock management, and door control. + Microscopy-specific logic (objectives, filter cubes, imaging) lives in + :class:`PicoMicroscopyBackend`. Args: host: IP address or hostname of the instrument. port: gRPC port (default 8091). - lock_timeout: Instrument lock timeout in seconds. The lock auto-releases if - no commands are sent within this period. - objectives: Mapping from 0-indexed turret position to :class:`Objective`. - Applied during :meth:`setup` via :meth:`change_objective`. Not all - positions need to be specified. - filter_cubes: Mapping from 0-indexed filter wheel position to - :class:`ImagingMode`. The filter cube for that mode is installed at - the given position. Applied during :meth:`setup` via - :meth:`change_filter_cube`. Not all positions need to be specified. + lock_timeout: Instrument lock timeout in seconds. """ def __init__( @@ -343,28 +333,12 @@ def __init__( 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 - for pos, obj in (objectives or {}).items(): - if obj not in _OBJECTIVE_MAP: - raise ValueError( - f"Objective {obj} not supported by Pico. Supported: {list(_OBJECTIVE_MAP.keys())}" - ) - for pos, mode in (filter_cubes or {}).items(): - if mode not in _IMAGING_MODE_MAP: - raise ValueError( - f"Imaging mode {mode} not supported by Pico. Supported: {list(_IMAGING_MODE_MAP.keys())}" - ) - - self._objectives: Dict[int, Objective] = objectives or {} - self._filter_cubes: Dict[int, ImagingMode] = filter_cubes or {} - self._channel: Optional["grpc.Channel"] = None self._lock_id: Optional[str] = None self._locked = False @@ -383,7 +357,6 @@ def _lock_metadata(self) -> List[Tuple[str, bytes]]: return [(_LOCK_META_KEY, metadata_lock_identifier(self._lock_id))] async def _relock(self) -> None: - """Force-release any stale lock and re-acquire.""" try: await self._unlock() except (grpc.RpcError, RuntimeError): @@ -464,23 +437,11 @@ async def _unlock(self) -> None: async def _initialize(self) -> None: await self._call(_INST_SVC, "Initialize", b"", with_lock=True) - async def _get_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 _get_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", [])) - # -- lifecycle -- - async def setup(self) -> None: + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: if not HAS_GRPC: - raise RuntimeError( - f"grpcio is required for the PicoBackend. Import error: {_GRPC_IMPORT_ERROR}" - ) + raise RuntimeError(f"grpcio is required for PicoDriver. Import error: {_GRPC_IMPORT_ERROR}") self._channel = grpc.insecure_channel( f"{self._host}:{self._port}", options=[ @@ -490,33 +451,13 @@ async def setup(self) -> None: ) self._lock_id = "pylabrobot" - # Try to unlock a stale lock from a previous session that didn't clean up. try: await self._unlock() except (grpc.RpcError, RuntimeError): pass await self._lock() - - installed_obj = await self._get_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, _OBJECTIVE_MAP[obj]) - - installed_fc = await self._get_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, _IMAGING_MODE_MAP[mode][1]) - - logger.info("PicoBackend: connected to %s:%d", self._host, self._port) + logger.info("PicoDriver: connected to %s:%d", self._host, self._port) async def stop(self) -> None: if self._channel is not None: @@ -527,21 +468,10 @@ async def stop(self) -> None: logger.warning("PicoBackend: unlock failed during stop: %s", e) self._channel.close() self._channel = None - logger.info("PicoBackend: stopped") - - # -- configuration -- - - async def get_configuration(self) -> dict: - """Query the full instrument configuration (objectives, filter cubes, etc.). - - Returns the parsed InstrumentConfiguration JSON from the instrument. Key fields: - - ``objectivesComponent.objectives``: list of installed objectives, each with - ``Id``, ``Description``, ``PositionLabel``, ``Magnification``, ``NumericalAperture`` - - ``filterCubesComponent.filterCubes``: list of installed filter cubes, each with - ``Id``, ``Description``, ``PositionLabel`` - - ``excitationSources``: list of excitation sources with ``Id`` - """ + 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)) @@ -550,90 +480,98 @@ async def get_configuration(self) -> dict: @property def door_open(self) -> bool: - """Whether the plate drawer is currently open (tracked client-side).""" return self._door_open async def open_door(self) -> None: - """Open the plate drawer.""" - await self._initialize() await self._call(_HW_SVC, "OpenPlateDrawer", b"", True) self._door_open = True async def close_door(self) -> None: - """Close the plate drawer.""" - await self._initialize() await self._call(_HW_SVC, "ClosePlateDrawer", b"", True) self._door_open = False - # -- objective maintenance -- - async def enter_objective_maintenance(self, position: int) -> None: - """Open the objective door for swapping objectives. +class PicoMicroscopyBackend(MicroscopyBackend): + """Translates MicroscopyBackend calls into Pico driver RPCs. - Args: - position: 0-indexed objective turret position. - """ + Owns objective/filter cube configuration, imaging protocol, and + objective maintenance. Uses the driver for raw gRPC calls. - 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) + Args: + driver: The Pico gRPC driver. + objectives: Mapping from 0-indexed turret position to :class:`Objective`. + filter_cubes: Mapping from 0-indexed filter wheel position to :class:`ImagingMode`. + """ - async def exit_objective_maintenance(self) -> None: - """Close the objective door after swapping objectives.""" + def __init__( + self, + driver: PicoDriver, + objectives: Optional[Dict[int, Objective]] = None, + filter_cubes: Optional[Dict[int, ImagingMode]] = None, + ): + self.driver = driver + + for pos, obj in (objectives or {}).items(): + if obj not in _OBJECTIVE_MAP: + raise ValueError( + f"Objective {obj} not supported by Pico. Supported: {list(_OBJECTIVE_MAP.keys())}" + ) + for pos, mode in (filter_cubes or {}).items(): + if mode not in _IMAGING_MODE_MAP: + raise ValueError( + f"Imaging mode {mode} not supported by Pico. Supported: {list(_IMAGING_MODE_MAP.keys())}" + ) + + self._objectives: Dict[int, Objective] = objectives or {} + self._filter_cubes: Dict[int, ImagingMode] = filter_cubes or {} + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + 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, _OBJECTIVE_MAP[obj]) - await self._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) + 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, _IMAGING_MODE_MAP[mode][1]) - async def get_available_objectives(self, position: int) -> List[dict]: - """Query which objectives are compatible with a given turret position. + # -- objectives & filter cubes -- - Args: - position: 0-indexed turret position. + async def _request_installed_objectives(self) -> List[dict]: + raw = await self.driver._call(_OBJ_SVC, "Get_InstalledObjectives", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("objectivesData", [])) - Returns: - List of objective dicts, each with ``Id``, ``Description``, ``Magnification``, - ``NumericalAperture``, ``PositionLabel``, ``IsCalibrated``, etc. - """ + async def _request_installed_filter_cubes(self) -> List[dict]: + raw = await self.driver._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) + raw = await self.driver._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 get_available_filter_cubes(self) -> List[dict]: - """Query which filter cubes are compatible with this instrument. - - Returns: - List of filter cube dicts, each with ``Id``, ``Description``, - ``PositionLabel``, ``IsCalibrated``, ``EmissionFilterPassBands``, - ``ExcitationFilterPassBands``, etc. - """ - - raw = await self._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") + async def request_available_filter_cubes(self) -> List[dict]: + raw = await self.driver._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: - """Register a new objective in a turret position. - - Call this after physically swapping an objective (during maintenance mode) - to update the instrument's configuration. - - Args: - position: 0-indexed turret position. - objective_id: Objective ID string (e.g. ``"PL FLUOTAR 4x/0.13"``). - Use :meth:`get_available_objectives` to list valid IDs for a position. - - Raises: - ValueError: If ``objective_id`` is not compatible with the given position. - """ - - available = await self.get_available_objectives(position) + 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( @@ -642,24 +580,10 @@ async def change_objective(self, position: int, objective_id: str) -> None: ) params = json.dumps({"Id": objective_id, "Index": position}) req = length_delimited(1, sila_string(params)) - await self._call(_OBJ_SVC, "ChangeHardware", req, True) + await self.driver._call(_OBJ_SVC, "ChangeHardware", req, True) async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: - """Register a new filter cube in a filter wheel position. - - Call this after physically swapping a filter cube to update the - instrument's configuration. - - Args: - position: 0-indexed filter wheel position. - filter_cube_id: Filter cube ID string (e.g. ``"FITC"``). - Use :meth:`get_available_filter_cubes` to list valid IDs. - - Raises: - ValueError: If ``filter_cube_id`` is not a compatible filter cube. - """ - - available = await self.get_available_filter_cubes() + 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( @@ -668,31 +592,39 @@ async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: ) params = json.dumps({"Id": filter_cube_id, "Index": position}) req = length_delimited(1, sila_string(params)) - await self._call(_FC_SVC, "ChangeHardware", req, True) + await self.driver._call(_FC_SVC, "ChangeHardware", req, True) + + async def enter_objective_maintenance(self, position: int) -> None: + if self.driver.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.driver._initialize() + await self.driver._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) + + async def exit_objective_maintenance(self) -> None: + await self.driver._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) # -- imaging -- async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[dict]: - """Acquire images via the SiLA 2 Observable Command flow.""" labware_json = json.dumps(labware_params) snap_json = json.dumps(snap_params) - await self._initialize() + await self.driver._initialize() - # Step 1: launch SnapImages command request = _snap_images_params(labware_json, snap_json) - confirmation_raw = await self._call( + confirmation_raw = await self.driver._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]) - # Step 2: stream intermediate responses (chunked image data) 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( + for response_raw in await self.driver._stream( _SNAP_SVC, "SnapImages_Intermediate", uuid_request, @@ -703,10 +635,10 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di chunks[meta["blob_index"]][meta["packet_index"]] = chunk_data checksums[meta["blob_index"]] = meta["blob_checksum"] - # Step 3: get result (signals command completion) - await self._call(_SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0) + await self.driver._call( + _SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0 + ) - # Step 4: reassemble blobs and verify checksums images = [] for blob_idx in sorted(chunks.keys()): blob_chunks = chunks[blob_idx] @@ -738,7 +670,16 @@ async def capture( focal_height: FocalPosition, gain: Gain, plate: Plate, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: + logger.info( + "[Pico %s:%s] capture: row=%d, col=%d, mode=%s", + self.driver._host, + self.driver._port, + row, + column, + mode, + ) if mode not in _IMAGING_MODE_MAP: raise ValueError( f"Unsupported imaging mode {mode} for Pico. Supported: {list(_IMAGING_MODE_MAP.keys())}" diff --git a/pylabrobot/molecular_devices/imageXpress/pico/pico.py b/pylabrobot/molecular_devices/imageXpress/pico/pico.py new file mode 100644 index 00000000000..dc595921539 --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/pico.py @@ -0,0 +1,65 @@ +from typing import Dict, Optional + +from pylabrobot.capabilities.microscopy import ImagingMode, Microscopy, Objective +from pylabrobot.device import Device +from pylabrobot.resources import Resource, Rotation + +from .backend import PicoDriver, PicoMicroscopyBackend + + +class Pico(Resource, Device): + """Molecular Devices ImageXpress Pico automated microscope. + + Args: + name: Unique resource name. + host: IP address or hostname of the instrument. + port: gRPC port (default 8091). + lock_timeout: Instrument lock timeout in seconds. + objectives: Mapping from 0-indexed turret position to :class:`Objective`. + filter_cubes: Mapping from 0-indexed filter wheel position to :class:`ImagingMode`. + size_x: Instrument footprint X in mm. + size_y: Instrument footprint Y in mm. + size_z: Instrument footprint Z in mm. + """ + + def __init__( + self, + name: str, + host: str, + port: int = 8091, + lock_timeout: int = 3600, + objectives: Optional[Dict[int, Objective]] = None, + filter_cubes: Optional[Dict[int, ImagingMode]] = None, + size_x: float = 460.0, + size_y: float = 430.0, + size_z: float = 480.0, + rotation: Optional[Rotation] = None, + category: Optional[str] = "microscope", + model: Optional[str] = "ImageXpress Pico", + ): + driver = PicoDriver( + host=host, + port=port, + lock_timeout=lock_timeout, + ) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.driver: PicoDriver = driver + + self.microscope = Microscopy( + backend=PicoMicroscopyBackend( + driver=driver, + objectives=objectives, + filter_cubes=filter_cubes, + ) + ) + self._capabilities = [self.microscope] diff --git a/pylabrobot/molecular_devices/spectramax/__init__.py b/pylabrobot/molecular_devices/spectramax/__init__.py new file mode 100644 index 00000000000..e458bfed5d1 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/__init__.py @@ -0,0 +1,29 @@ +from .backend import ( + COMMAND_TERMINATORS, + ERROR_CODES, + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesError, + MolecularDevicesFirmwareError, + MolecularDevicesHardwareError, + MolecularDevicesMotionError, + MolecularDevicesNVRAMError, + MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from .spectramax_384_plus import SpectraMax384Plus, SpectraMax384PlusAbsorbanceBackend +from .spectramax_m5 import ( + SpectraMaxM5, + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) diff --git a/pylabrobot/plate_reading/molecular_devices/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py similarity index 61% rename from pylabrobot/plate_reading/molecular_devices/backend.py rename to pylabrobot/molecular_devices/spectramax/backend.py index fa573388958..15a75b38fee 100644 --- a/pylabrobot/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -2,16 +2,21 @@ import logging import re import time -from abc import ABCMeta from dataclasses import dataclass, field from enum import Enum from typing import Dict, List, Literal, Optional, Tuple, Union +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial -from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) RES_TERM_CHAR = b">" COMMAND_TERMINATORS: Dict[str, int] = { @@ -63,6 +68,11 @@ } +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + class MolecularDevicesError(Exception): """Exceptions raised by a Molecular Devices plate reader.""" @@ -142,6 +152,11 @@ class MolecularDevicesNVRAMError(MolecularDevicesError): MolecularDevicesResponse = List[str] +# --------------------------------------------------------------------------- +# Enums & settings dataclasses +# --------------------------------------------------------------------------- + + class ReadMode(Enum): """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" @@ -246,24 +261,38 @@ class MolecularDevicesSettings: settling_time: int = 0 -class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): - """Backend for Molecular Devices plate readers.""" +# --------------------------------------------------------------------------- +# Driver — serial I/O and device-level operations +# --------------------------------------------------------------------------- + - def __init__(self, port: str) -> None: +class MolecularDevicesDriver(Driver): + """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="Molecular Devices Plate Reader", + human_readable_device_name=human_readable_device_name, port=self.port, baudrate=9600, timeout=0.2, ) - async def setup(self) -> None: + async def setup(self, backend_params: Optional[BackendParams] = None) -> 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) def serialize(self) -> dict: return {**super().serialize(), "port": self.port} @@ -287,10 +316,12 @@ async def send_command( 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 - logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) 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) @@ -298,9 +329,9 @@ async def send_command( 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 MolecularDevicesError(f"Command '{command}' failed with empty response.") - # Check for FAIL in the 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") @@ -321,62 +352,80 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: if not any("OK" in r for r in response): raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") if "warning" in response[0].lower(): - logger.warning("Warning for command '%s': %s", command, response) + 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, plate: Optional[Plate] = None) -> None: + async def close(self) -> None: + """Close the plate tray.""" await self.send_command("!CLOSE") - async def get_status(self) -> List[str]: + 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 get_temperature(self) -> Tuple[float, float]: - 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])) # current, set_point - raise ValueError(f"Could not parse temperature from response: {res}") - - async def set_temperature(self, temperature: float) -> None: - if not (0 <= temperature <= 45): - raise ValueError("Temperature must be between 0 and 45°C.") - await self.send_command(f"!TEMP {temperature}") - - async def get_firmware_version(self) -> List[str]: + 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) + + +# --------------------------------------------------------------------------- +# Shared protocol helpers — used by all capability backends +# --------------------------------------------------------------------------- + + +class _MolecularDevicesProtocol: + """Mixin with shared _set_* command builders for Molecular Devices readers. + + Subclasses must have ``self.driver: MolecularDevicesDriver``. + """ + + driver: MolecularDevicesDriver + async def _read_now(self) -> None: - await self.send_command("!READ") + await self.driver.send_command("!READ") async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: - """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each - reading and combine them into a single collection. - """ + """Transfer data from the plate reader.""" if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) or ( settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings @@ -390,14 +439,13 @@ async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict] all_reads = [] for _ in range(num_readings): - res = await self.send_command("!TRANSFER") + res = await self.driver.send_command("!TRANSFER") data_str = res[1] read_data = self._parse_data(data_str, settings) - all_reads.extend(read_data) # Unpack the list + all_reads.extend(read_data) return all_reads - # For ENDPOINT - res = await self.send_command("!TRANSFER") + res = await self.driver.send_command("!TRANSFER") data_str = res[1] return self._parse_data(data_str, settings) @@ -405,29 +453,23 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] - # 1. Parse header header_parts = lines[0].split("\t") measurement_time = float(header_parts[0]) temperature = float(header_parts[1]) - # 2. Parse wavelengths line_idx = 1 while line_idx < len(lines): line = lines[line_idx] if line.startswith("L:") and line_idx > 1: - # Data section started break line_idx += 1 data_collection = [] cur_read_wavelengths = [] - # 3. Parse data data_columns: List[List[float]] = [] - # The data section starts at line_idx for i in range(line_idx, len(lines)): line = lines[i] if line.startswith("L:"): - # start of a new data with different wavelength cur_read_wavelengths.append(line.split("\t")[1:]) if i > line_idx and data_columns: data_collection.append(data_columns) @@ -447,7 +489,6 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List if data_columns: data_collection.append(data_columns) - # 4. Transpose data to be row-major data_collection_transposed = [] for data_columns in data_collection: data_rows = [] @@ -470,7 +511,7 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List if read_mode == ReadMode.ABS: wl = int(cur_read_wavelengths[i][0]) measurement["wavelength"] = wl - elif read_mode == ReadMode.FLU or read_mode == ReadMode.POLAR or read_mode == ReadMode.TIME: + elif read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): ex_wl = int(cur_read_wavelengths[i][0]) em_wl = int(cur_read_wavelengths[i][1]) measurement["ex_wavelength"] = ex_wl @@ -483,7 +524,7 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List return measurements async def _set_clear(self) -> None: - await self.send_command("!CLEAR DATA") + await self.driver.send_command("!CLEAR DATA") async def _set_mode(self, settings: MolecularDevicesSettings) -> None: cmd = f"!MODE {settings.read_type.value}" @@ -495,7 +536,7 @@ async def _set_mode(self, settings: MolecularDevicesSettings) -> None: 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) + await self.driver.send_command(cmd) async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: @@ -505,17 +546,17 @@ async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: wl_str = " ".join(wl_parts) if settings.path_check: wl_str += " 900 998" - await self.send_command(f"!WAVELENGTH {wl_str}") + await self.driver.send_command(f"!WAVELENGTH {wl_str}") elif settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): 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}") + await self.driver.send_command(f"!EXWAVELENGTH {ex_wl_str}") + await self.driver.send_command(f"!EMWAVELENGTH {em_wl_str}") elif settings.read_mode == ReadMode.LUM: wl_str = " ".join(map(str, settings.emission_wavelengths)) - await self.send_command(f"!EMWAVELENGTH {wl_str}") + await self.driver.send_command(f"!EMWAVELENGTH {wl_str}") else: - raise NotImplementedError("f{settings.read_mode} not supported") + raise NotImplementedError(f"{settings.read_mode} not supported") async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: plate = settings.plate @@ -536,15 +577,15 @@ async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: 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) + await self.driver.send_command(x_pos_cmd) + await self.driver.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}") + await self.driver.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") + await self.driver.send_command("!SHAKE OFF") return ss = settings.shake_settings shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" @@ -556,31 +597,33 @@ async def _set_shake(self, settings: MolecularDevicesSettings) -> None: 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") + await self.driver.send_command(f"!SHAKE {shake_mode}") + await self.driver.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 {settings.carriage_speed.value}") + await self.driver.send_command(f"!CSPEED {settings.carriage_speed.value}") async def _set_read_stage(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): stage = "BOT" if settings.read_from_bottom else "TOP" - await self.send_command(f"!READSTAGE {stage}") + await self.driver.send_command(f"!READSTAGE {stage}") async def _set_flashes_per_well(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - await self.send_command(f"!FPW {settings.flashes_per_well}") + await self.driver.send_command(f"!FPW {settings.flashes_per_well}") async def _set_pmt(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): return gain = settings.pmt_gain if gain == PmtGain.AUTO: - await self.send_command("!AUTOPMT ON") + await self.driver.send_command("!AUTOPMT ON") else: gain_val = gain.value if isinstance(gain, PmtGain) else gain - await self.send_command("!AUTOPMT OFF") - await self.send_command(f"!PMT {gain_val}") + await self.driver.send_command("!AUTOPMT OFF") + await self.driver.send_command(f"!PMT {gain_val}") async def _set_filter(self, settings: MolecularDevicesSettings) -> None: if ( @@ -588,24 +631,24 @@ async def _set_filter(self, settings: MolecularDevicesSettings) -> None: 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}") + await self.driver.send_command("!AUTOFILTER OFF") + await self.driver.send_command(f"!EMFILTER {cf_str}") else: - await self.send_command("!AUTOFILTER ON") + await self.driver.send_command("!AUTOFILTER ON") async def _set_calibrate(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: - await self.send_command(f"!CALIBRATE {settings.calibrate.value}") + await self.driver.send_command(f"!CALIBRATE {settings.calibrate.value}") else: - await self.send_command(f"!PMTCAL {settings.calibrate.value}") + await self.driver.send_command(f"!PMTCAL {settings.calibrate.value}") async def _set_order(self, settings: MolecularDevicesSettings) -> None: - await self.send_command(f"!ORDER {settings.read_order.value}") + await self.driver.send_command(f"!ORDER {settings.read_order.value}") async def _set_speed(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: mode = "ON" if settings.speed_read else "OFF" - await self.send_command(f"!SPEED {mode}") + await self.driver.send_command(f"!SPEED {mode}") async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR: @@ -614,13 +657,13 @@ async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: else: command = "CARCOL" value = settings.settling_time if settings.settling_time > 100 else 100 - await self.send_command(f"!NVRAM {command} {value}") + await self.driver.send_command(f"!NVRAM {command} {value}") async def _set_tag(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR and settings.read_type == ReadType.KINETIC: - await self.send_command("!TAG ON") + await self.driver.send_command("!TAG ON") else: - await self.send_command("!TAG OFF") + await self.driver.send_command("!TAG OFF") async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: """Set the READTYPE command and the expected number of response fields.""" @@ -644,19 +687,17 @@ async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: else: raise ValueError(f"Unsupported read mode: {settings.read_mode}") - await self.send_command(cmd, num_res_fields=num_res_fields) + await self.driver.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 == ReadMode.TIME: - await self.send_command(f"!COUNTTIMEDELAY {delay_time}") - await self.send_command(f"!COUNTTIME {integration_time * 0.001}") + await self.driver.send_command(f"!COUNTTIMEDELAY {delay_time}") + await self.driver.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.""" - # This map is a direct translation of the `EmissionCutoff.CutoffFilter` in MaxlineModel.cs - # (min_wavelength, max_wavelength, cutoff_filter_index) FILTERS = [ (0, 322, 1), (325, 415, 16), @@ -680,52 +721,94 @@ def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: return cutoff_filter_index raise ValueError(f"No cutoff filter found for wavelength {wavelength}") - 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: - raise TimeoutError("Timeout waiting for plate reader to become idle.") - status = await self.get_status() - if status and status[1] == "IDLE": - break - await asyncio.sleep(1) - async def read_absorbance( # type: ignore[override] +# --------------------------------------------------------------------------- +# Capability backends +# --------------------------------------------------------------------------- + + +class MolecularDevicesAbsorbanceBackend(_MolecularDevicesProtocol, AbsorbanceBackend): + """Translates AbsorbanceBackend interface into Molecular Devices commands.""" + + def __init__(self, driver: MolecularDevicesDriver) -> None: + self.driver = driver + + @dataclass + class AbsorbanceParams(BackendParams): + """Molecular Devices parameters for absorbance reads. + + Args: + 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 the + wavelength from the ``read_absorbance`` call. + read_type: Read type (endpoint, kinetic, spectrum, or well scan). Default ENDPOINT. + read_order: Read order (column or row). Default COLUMN. + calibrate: Calibration mode (once, always, or never). Default ONCE. + shake_settings: Optional shake settings to apply before reading. + carriage_speed: Carriage speed (normal or low). Default NORMAL. + speed_read: If True, enable speed read mode for faster measurements with reduced + accuracy. Default False. + path_check: If True, enable PathCheck pathlength correction for absorbance values. + Default False. + 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. Default False. + settling_time: Settling time in milliseconds before reading. Default 0. + timeout: Read timeout in seconds. Default 600. + """ + + wavelengths: Optional[List[Union[int, Tuple[int, bool]]]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = 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 + + async def read_absorbance( self, plate: Plate, - wavelengths: List[Union[int, Tuple[int, bool]]], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = 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[Dict]: + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + if not isinstance(backend_params, self.AbsorbanceParams): + backend_params = MolecularDevicesAbsorbanceBackend.AbsorbanceParams() + + wavelengths = ( + backend_params.wavelengths if backend_params.wavelengths is not None else [wavelength] + ) + logger.info( + "[SpectraMax %s] read absorbance: plate='%s', wavelengths=%s nm", + self.driver.port, + plate.name, + wavelengths, + ) settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.ABS, - 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, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + speed_read=backend_params.speed_read, + path_check=backend_params.path_check, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, wavelengths=wavelengths, - cuvette=cuvette, - settling_time=settling_time, + cuvette=backend_params.cuvette, + settling_time=backend_params.settling_time, ) await self._set_clear() - if not cuvette: + if not backend_params.cuvette: await self._set_plate_position(settings) await self._set_strip(settings) await self._set_carriage_speed(settings) @@ -741,258 +824,52 @@ async def read_absorbance( # type: ignore[override] 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_fluorescence( # type: ignore[override] - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.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]: - """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.FLU, - 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) + await self.driver.wait_for_idle(timeout=backend_params.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 + ] - async def read_luminescence( # type: ignore[override] - self, - plate: Plate, - emission_wavelengths: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.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[Dict]: - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.LUM, - 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) +class MolecularDevicesTemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend interface into Molecular Devices commands.""" - 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) + def __init__(self, driver: MolecularDevicesDriver) -> None: + self.driver = driver - await self._read_now() - await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) + @property + def supports_active_cooling(self) -> bool: + return False - async def read_fluorescence_polarization( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.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]: - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.POLAR, - 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 request_temperature(self) -> Tuple[float, float]: + """Get (current_temp, set_point) from the device.""" + res = await self.driver.send_command("!TEMP") + if len(res) > 1: + parts = res[1].split() + else: + parts = res[0].replace("OK", "").split() - 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 = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.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]: - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.TIME, - 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 len(parts) >= 2: + return (float(parts[1]), float(parts[0])) + raise ValueError(f"Could not parse temperature from response: {res}") - if not cuvette: - await self._set_plate_position(settings) - await self._set_strip(settings) - await self._set_carriage_speed(settings) + async def request_current_temperature(self) -> float: + current, _ = await self.request_temperature() + logger.info("[SpectraMax %s] read temperature: actual=%.1f C", self.driver.port, current) + return current - 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) + 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.driver.port, temperature) + await self.driver.send_command(f"!TEMP {temperature}") - await self._read_now() - await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) + async def deactivate(self) -> None: + logger.info("[SpectraMax %s] deactivate temperature control", self.driver.port) + await self.driver.send_command("!TEMP 0") diff --git a/pylabrobot/molecular_devices/spectramax/backend_tests.py b/pylabrobot/molecular_devices/spectramax/backend_tests.py new file mode 100644 index 00000000000..b769668972c --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/backend_tests.py @@ -0,0 +1,633 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.molecular_devices.spectramax.backend import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesSettings, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import ( + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) +from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul + + +class TestMolecularDevicesBackend(unittest.IsolatedAsyncioTestCase): + """Tests for MolecularDevicesAbsorbanceBackend and the protocol mixin.""" + + backend: MolecularDevicesAbsorbanceBackend + driver: MolecularDevicesDriver + 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.driver = MolecularDevicesDriver(port="COM1") + self.driver.io = self.mock_serial + self.backend = MolecularDevicesAbsorbanceBackend(driver=self.driver) + self.send_command_mock = patch.object( + self.driver, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) + + async def test_setup_stop(self): + with patch.object( + self.driver, "send_command", wraps=self.driver.send_command + ) as wrapped_send_command: + await self.driver.setup() + self.mock_serial.setup.assert_called_once() + wrapped_send_command.assert_called_with("!") + await self.driver.stop() + self.mock_serial.stop.assert_called_once() + + async def test_set_clear(self): + await self.backend._set_clear() + self.send_command_mock.assert_called_once_with("!CLEAR DATA") + + async def test_set_mode(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE ENDPOINT") + + self.send_command_mock.reset_mock() + settings.read_type = ReadType.KINETIC + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + await self.backend._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE KINETIC 10 5") + + self.send_command_mock.reset_mock() + settings.read_type = ReadType.SPECTRUM + settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) + await self.backend._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.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + wavelengths=[500, (600, True)], + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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.backend._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 = ReadMode.FLU + settings.excitation_wavelengths = [485] + settings.emission_wavelengths = [520] + await self.backend._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 = ReadMode.LUM + settings.emission_wavelengths = [590] + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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.backend._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.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._set_carriage_speed(settings) + self.send_command_mock.assert_called_once_with("!CSPEED 8") + self.send_command_mock.reset_mock() + settings.carriage_speed = CarriageSpeed.SLOW + await self.backend._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=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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.backend._set_read_stage(settings) + self.send_command_mock.assert_called_once_with("!READSTAGE BOT") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + await self.backend._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=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + flashes_per_well=10, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._set_flashes_per_well(settings) + self.send_command_mock.assert_called_once_with("!FPW 10") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + await self.backend._set_flashes_per_well(settings) + self.send_command_mock.assert_not_called() + + async def test_set_pmt(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + pmt_gain=PmtGain.AUTO, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._set_pmt(settings) + self.send_command_mock.assert_called_once_with("!AUTOPMT ON") + self.send_command_mock.reset_mock() + settings.pmt_gain = PmtGain.HIGH + await self.backend._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.backend._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 = ReadMode.ABS + await self.backend._set_pmt(settings) + self.send_command_mock.assert_not_called() + + async def test_set_filter(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + cutoff_filters=[self.backend._get_cutoff_filter_index_from_wavelength(535), 9], + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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.backend._set_filter(settings) + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + settings.cutoff_filters = [515, 530] + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._set_calibrate(settings) + self.send_command_mock.assert_called_once_with("!CALIBRATE ON") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.FLU + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._set_order(settings) + self.send_command_mock.assert_called_once_with("!ORDER COLUMN") + self.send_command_mock.reset_mock() + settings.read_order = ReadOrder.WAVELENGTH + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=True, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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.backend._set_speed(settings) + self.send_command_mock.assert_called_once_with("!SPEED OFF") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.FLU + await self.backend._set_speed(settings) + self.send_command_mock.assert_not_called() + + async def test_set_integration_time(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.TIME, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._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 = ReadMode.ABS + await self.backend._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=ReadMode.POLAR, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=5, + ) + await self.backend._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=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=10, + ) + await self.backend._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.backend._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=ReadMode.POLAR, + read_type=ReadType.KINETIC, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=KineticSettings(interval=10, num_readings=5), + spectrum_settings=None, + ) + await self.backend._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG ON") + self.send_command_mock.reset_mock() + settings.read_type = ReadType.ENDPOINT + await self.backend._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG OFF") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + settings.read_type = ReadType.KINETIC + await self.backend._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG OFF") + + async def test_read_absorbance(self): + with ( + patch.object(self.backend, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.driver, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.backend, + "_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.backend.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 TestSpectraMaxM5Backend(unittest.IsolatedAsyncioTestCase): + """Tests for SpectraMaxM5 fluorescence and luminescence backends.""" + + flu_backend: SpectraMaxM5FluorescenceBackend + lum_backend: SpectraMaxM5LuminescenceBackend + driver: MolecularDevicesDriver + 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.driver = MolecularDevicesDriver( + port="COM1", human_readable_device_name="Molecular Devices SpectraMax M5" + ) + self.driver.io = self.mock_serial + self.flu_backend = SpectraMaxM5FluorescenceBackend(driver=self.driver) + self.lum_backend = SpectraMaxM5LuminescenceBackend(driver=self.driver) + self.send_command_mock = patch.object( + self.driver, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) + + async def test_read_fluorescence(self): + with ( + patch.object(self.flu_backend, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.driver, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.flu_backend, + "_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.flu_backend.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.lum_backend, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.driver, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.lum_backend, + "_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.lum_backend.read_luminescence( + plate, + plate.get_wells(), + focal_height=0, + backend_params=SpectraMaxM5LuminescenceBackend.LuminescenceParams( + 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/loading_tray_backend.py b/pylabrobot/molecular_devices/spectramax/loading_tray_backend.py new file mode 100644 index 00000000000..498896e55fc --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/loading_tray_backend.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray.backend import LoadingTrayBackend + +from .backend import MolecularDevicesDriver + + +class MolecularDevicesLoadingTrayBackend(LoadingTrayBackend): + """Loading tray backend for Molecular Devices plate readers.""" + + def __init__(self, driver: MolecularDevicesDriver): + self._driver = driver + + async def open(self, backend_params: Optional[BackendParams] = None): + await self._driver.send_command("!OPEN") + + async def close(self, backend_params: Optional[BackendParams] = None): + await self._driver.send_command("!CLOSE") 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..12b43a1dd67 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py @@ -0,0 +1,77 @@ +from pylabrobot.capabilities.loading_tray import HasLoadingTray, LoadingTray +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Resource + +from .backend import ( + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, +) +from .loading_tray_backend import MolecularDevicesLoadingTrayBackend + + +class SpectraMax384PlusAbsorbanceBackend(MolecularDevicesAbsorbanceBackend): + """Absorbance backend for Molecular Devices SpectraMax 384 Plus plate readers. + + Overrides ``_set_readtype`` (simpler CUV/PLA), and no-ops + ``_set_nvram`` / ``_set_tag``. + """ + + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" + await self.driver.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 + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class SpectraMax384Plus(Resource, Device, HasLoadingTray): + """Molecular Devices SpectraMax 384 Plus plate reader. Absorbance only.""" + + def __init__( + self, + name: str, + port: str, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + driver = MolecularDevicesDriver( + port=port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus" + ) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Molecular Devices SpectraMax 384 Plus", + ) + Device.__init__(self, driver=driver) + self.driver: MolecularDevicesDriver = driver + self.absorbance = Absorbance(backend=SpectraMax384PlusAbsorbanceBackend(driver)) + self.tc = TemperatureController(backend=MolecularDevicesTemperatureBackend(driver)) + self.loading_tray = LoadingTray( + backend=MolecularDevicesLoadingTrayBackend(driver), + name=name + "_loading_tray", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + self._capabilities = [self.absorbance, self.tc, self.loading_tray] + self.assign_child_resource(self.loading_tray, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py new file mode 100644 index 00000000000..8f88751e125 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -0,0 +1,483 @@ +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray import HasLoadingTray, LoadingTray +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .backend import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, + _MolecularDevicesProtocol, +) +from .loading_tray_backend import MolecularDevicesLoadingTrayBackend + +logger = logging.getLogger(__name__) + + +class SpectraMaxM5FluorescenceBackend(_MolecularDevicesProtocol, FluorescenceBackend): + """Translates FluorescenceBackend interface into SpectraMax M5 commands.""" + + def __init__(self, driver: MolecularDevicesDriver) -> None: + self.driver = driver + + @dataclass + class FluorescenceParams(BackendParams): + """SpectraMax M5 parameters for fluorescence reads. + + Args: + excitation_wavelengths: List of excitation wavelengths in nm. If None, uses the + wavelength from the ``read_fluorescence`` call. + emission_wavelengths: List of emission wavelengths in nm. If None, uses the + wavelength from the ``read_fluorescence`` call. + 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). Default ENDPOINT. + read_order: Read order (column or row). Default COLUMN. + calibrate: Calibration mode (once, always, or never). Default ONCE. + shake_settings: Optional shake settings to apply before reading. + carriage_speed: Carriage speed (normal or low). Default NORMAL. + read_from_bottom: If True, read from the bottom of the plate. Default False. + pmt_gain: PMT gain setting (AUTO or a manual integer value). Default AUTO. + flashes_per_well: Number of flashes per well. Default 10. + 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. Default False. + settling_time: Settling time in milliseconds before reading. Default 0. + timeout: Read timeout in seconds. Default 600. + """ + + excitation_wavelengths: Optional[List[int]] = None + emission_wavelengths: Optional[List[int]] = None + cutoff_filters: Optional[List[int]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.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 + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + if not isinstance(backend_params, self.FluorescenceParams): + backend_params = SpectraMaxM5FluorescenceBackend.FluorescenceParams() + + logger.info( + "[SpectraMax M5 %s] read fluorescence: plate=%s, ex=%d nm, em=%d nm, wells=%d", + self.driver.port, + plate.name, + excitation_wavelength, + emission_wavelength, + len(wells), + ) + excitation_wavelengths = backend_params.excitation_wavelengths or [excitation_wavelength] + emission_wavelengths = backend_params.emission_wavelengths or [emission_wavelength] + cutoff_filters = backend_params.cutoff_filters + if cutoff_filters is None: + cutoff_filters = [self._get_cutoff_filter_index_from_wavelength(emission_wavelength)] + + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.FLU, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + read_from_bottom=backend_params.read_from_bottom, + pmt_gain=backend_params.pmt_gain, + flashes_per_well=backend_params.flashes_per_well, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=backend_params.cuvette, + speed_read=False, + settling_time=backend_params.settling_time, + ) + await self._set_clear() + if not backend_params.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.driver.wait_for_idle(timeout=backend_params.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 = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.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.driver.port, + plate.name, + excitation_wavelengths, + emission_wavelengths, + ) + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.POLAR, + 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.driver.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 = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.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.driver.port, + plate.name, + excitation_wavelengths, + emission_wavelengths, + ) + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.TIME, + 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.driver.wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + +class SpectraMaxM5LuminescenceBackend(_MolecularDevicesProtocol, LuminescenceBackend): + """Translates LuminescenceBackend interface into SpectraMax M5 commands.""" + + def __init__(self, driver: MolecularDevicesDriver) -> None: + self.driver = driver + + @dataclass + class LuminescenceParams(BackendParams): + """SpectraMax M5 parameters for luminescence reads. + + Args: + emission_wavelengths: List of emission wavelengths in nm. Required for + SpectraMax M5 luminescence reads. + read_type: Read type (endpoint, kinetic, spectrum, or well scan). Default ENDPOINT. + read_order: Read order (column or row). Default COLUMN. + calibrate: Calibration mode (once, always, or never). Default ONCE. + shake_settings: Optional shake settings to apply before reading. + carriage_speed: Carriage speed (normal or low). Default NORMAL. + read_from_bottom: If True, read from the bottom of the plate. Default False. + pmt_gain: PMT gain setting (AUTO or a manual integer value). Default AUTO. + flashes_per_well: Number of flashes per well. Default 0 (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. Default False. + settling_time: Settling time in milliseconds before reading. Default 0. + timeout: Read timeout in seconds. Default 600. + """ + + emission_wavelengths: Optional[List[int]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.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 + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = SpectraMaxM5LuminescenceBackend.LuminescenceParams() + + logger.info( + "[SpectraMax M5 %s] read luminescence: plate=%s, wells=%d", + self.driver.port, + plate.name, + len(wells), + ) + if backend_params.emission_wavelengths is None: + raise ValueError("emission_wavelengths is required for SpectraMax M5 luminescence reads") + + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.LUM, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + read_from_bottom=backend_params.read_from_bottom, + pmt_gain=backend_params.pmt_gain, + flashes_per_well=backend_params.flashes_per_well, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, + emission_wavelengths=backend_params.emission_wavelengths, + cuvette=backend_params.cuvette, + speed_read=False, + settling_time=backend_params.settling_time, + ) + await self._set_clear() + await self._set_read_stage(settings) + + if not backend_params.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.driver.wait_for_idle(timeout=backend_params.timeout) + dicts = await self._transfer_data(settings) + return [ + LuminescenceResult( + data=d["data"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class SpectraMaxM5(Resource, Device, HasLoadingTray): + """Molecular Devices SpectraMax M5 plate reader. + + Supports absorbance, fluorescence, and luminescence capabilities. + """ + + def __init__( + self, + name: str, + port: str, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + driver = MolecularDevicesDriver( + port=port, human_readable_device_name="Molecular Devices SpectraMax M5" + ) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Molecular Devices SpectraMax M5", + ) + Device.__init__(self, driver=driver) + self.driver: MolecularDevicesDriver = driver + self.absorbance = Absorbance(backend=MolecularDevicesAbsorbanceBackend(driver)) + self.luminescence = Luminescence(backend=SpectraMaxM5LuminescenceBackend(driver)) + self.fluorescence = Fluorescence(backend=SpectraMaxM5FluorescenceBackend(driver)) + self.tc = TemperatureController(backend=MolecularDevicesTemperatureBackend(driver)) + self.loading_tray = LoadingTray( + backend=MolecularDevicesLoadingTrayBackend(driver), + name=name + "_loading_tray", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + self._capabilities = [ + self.absorbance, + self.luminescence, + self.fluorescence, + self.tc, + self.loading_tray, + ] + self.assign_child_resource(self.loading_tray, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} 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/only_fans/fan.py b/pylabrobot/only_fans/fan.py deleted file mode 100644 index c34e784a9ee..00000000000 --- a/pylabrobot/only_fans/fan.py +++ /dev/null @@ -1,37 +0,0 @@ -import asyncio - -from pylabrobot.machines.machine import Machine - -from .backend import FanBackend - - -class Fan(Machine): - """ - Front end for Fans. - """ - - def __init__(self, backend: FanBackend): - super().__init__(backend=backend) - self.backend: FanBackend = backend # fix type - - async def stop(self): - await self.backend.turn_off() - await super().stop() - - async def turn_on(self, intensity: int, duration=None): - """Run the fan - - Args: - intensity: integer percent between 0 and 100 - duration: time to run the fan for. If None, run until `turn_off` is called. - """ - - await self.backend.turn_on(intensity=intensity) - - if duration is not None: - await asyncio.sleep(duration) - await self.backend.turn_off() - - async def turn_off(self): - """Turn the fan off, but do not close the connection.""" - await self.backend.turn_off() diff --git a/pylabrobot/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/only_fans/hamilton_hepa_fan_backend.py deleted file mode 100644 index 0d62c6143f2..00000000000 --- a/pylabrobot/only_fans/hamilton_hepa_fan_backend.py +++ /dev/null @@ -1,162 +0,0 @@ -import asyncio - -from pylabrobot.io.ftdi import FTDI - -from .backend import FanBackend - - -class HamiltonHepaFanBackend(FanBackend): - """Backend for Hepa fan attachment on Hamilton Liquid Handler""" - - def __init__(self, device_id=None): - self.io = FTDI( - human_readable_device_name="Hamilton HEPA Fan", device_id=device_id, vid=0x0856, pid=0xAC11 - ) - - async def setup(self): - await self.io.setup() - await self.io.set_baudrate(9600) - await self.io.set_line_property(8, 0, 0) # 8N1 - await self.io.set_latency_timer(16) - await self.io.set_flowctrl(512) - await self.io.set_dtr(True) - await self.io.set_rts(True) - - await self.send(b"\x55\xc1\x01\x02\x23\x4b") - await self.send(b"\x55\xc1\x01\x08\x08\x6a") - await self.send(b"\x55\xc1\x01\x09\x6a\x09") - await self.send(b"\x55\xc1\x01\x0a\x2f\x4f") - await self.send(b"\x15\x61\x01\x8a") - - async def turn_on(self, intensity): # Speed is an integer percent between 0 and 100 - if int(intensity) != intensity or not 0 <= intensity <= 100: - raise ValueError("Intensity is not an int value between 0 and 100") - await self.send(b"\x35\x41\x01\xff\x75") # turn on - - speed_array = [ - "55c10111007b", - "55c101110279", - "55c10111057e", - "55c10111077c", - "55c101110a71", - "55c101110c77", - "55c101110f74", - "55c10111116a", - "55c10111146f", - "55c10111166d", - "55c101111962", - "55c101111c67", - "55c101111e65", - "55c10111215a", - "55c101112358", - "55c10111265d", - "55c101112853", - "55c101112b50", - "55c101112d56", - "55c10111304b", - "55c101113249", - "55c10111354e", - "55c101113843", - "55c101113a41", - "55c101113d46", - "55c101113f44", - "55c101114239", - "55c10111443f", - "55c10111473c", - "55c101114932", - "55c101114c37", - "55c101114f34", - "55c10111512a", - "55c10111542f", - "55c10111562d", - "55c101115922", - "55c101115b20", - "55c101115e25", - "55c10111601b", - "55c101116318", - "55c10111651e", - "55c101116813", - "55c101116b10", - "55c101116d16", - "55c10111700b", - "55c101117209", - "55c10111750e", - "55c10111770c", - "55c101117a01", - "55c101117c07", - "55c101117f04", - "55c1011182f9", - "55c1011184ff", - "55c1011187fc", - "55c1011189f2", - "55c101118cf7", - "55c101118ef5", - "55c1011191ea", - "55c1011193e8", - "55c1011196ed", - "55c1011198e3", - "55c101119be0", - "55c101119ee5", - "55c10111a0db", - "55c10111a3d8", - "55c10111a5de", - "55c10111a8d3", - "55c10111aad1", - "55c10111add6", - "55c10111afd4", - "55c10111b2c9", - "55c10111b5ce", - "55c10111b7cc", - "55c10111bac1", - "55c10111bcc7", - "55c10111bfc4", - "55c10111c1ba", - "55c10111c4bf", - "55c10111c6bd", - "55c10111c9b2", - "55c10111cbb0", - "55c10111ceb5", - "55c10111d1aa", - "55c10111d3a8", - "55c10111d6ad", - "55c10111d8a3", - "55c10111dba0", - "55c10111dda6", - "55c10111e09b", - "55c10111e299", - "55c10111e59e", - "55c10111e893", - "55c10111ea91", - "55c10111ed96", - "55c10111ef94", - "55c10111f289", - "55c10111f48f", - "55c10111f78c", - "55c10111f982", - "55c10111fc87", - "55c10111fe85", - ] - - await self.send(bytes.fromhex(speed_array[intensity])) # set speed - - async def turn_off(self): - await self.send(b"\x55\xc1\x01\x11\x00\x7b") - - async def stop(self): - await self.io.stop() - - async def send(self, command: bytes): - await self.io.write(command) - await asyncio.sleep(0.1) - await self.io.read(64) - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class HamiltonHepaFan: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`HamiltonHepaFan` is deprecated. Please use `HamiltonHepaFanBackend` instead." - ) diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py new file mode 100644 index 00000000000..45ef90311f4 --- /dev/null +++ b/pylabrobot/opentrons/__init__.py @@ -0,0 +1,7 @@ +from .temperature_module import ( + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend, + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend, + OpentronsTemperatureModuleV2, +) diff --git a/pylabrobot/opentrons/temperature_module/__init__.py b/pylabrobot/opentrons/temperature_module/__init__.py new file mode 100644 index 00000000000..6204da3fc55 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/__init__.py @@ -0,0 +1,9 @@ +from .http_driver import ( + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend, +) +from .temperature_module import OpentronsTemperatureModuleV2 +from .usb_driver import ( + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend, +) diff --git a/pylabrobot/opentrons/temperature_module/http_driver.py b/pylabrobot/opentrons/temperature_module/http_driver.py new file mode 100644 index 00000000000..e361a8e2f3e --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/http_driver.py @@ -0,0 +1,78 @@ +import logging +from typing import Optional, cast + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver + +try: + import ot_api + + USE_OT = True +except ImportError as e: + USE_OT = False + _OT_IMPORT_ERROR = e + +logger = logging.getLogger(__name__) + + +class OpentronsTemperatureModuleDriver(Driver): + """Driver for the Opentrons Temperature Module v2 via the Opentrons HTTP API. + + Owns the ot_api dependency check. There is no persistent connection to manage, + so ``setup``/``stop`` are lightweight. + """ + + def __init__(self, opentrons_id: str): + super().__init__() + self.opentrons_id = opentrons_id + + if not USE_OT: + raise RuntimeError( + "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." + ) + + async def setup(self, backend_params: Optional[BackendParams] = None): + pass + + async def stop(self): + pass + + def serialize(self) -> dict: + return {**super().serialize(), "opentrons_id": self.opentrons_id} + + +class OpentronsTemperatureModuleTemperatureBackend(TemperatureControllerBackend): + """Translates ``TemperatureControllerBackend`` into Opentrons HTTP-API calls.""" + + def __init__(self, driver: OpentronsTemperatureModuleDriver): + self.driver = driver + + @property + def supports_active_cooling(self) -> bool: + return False + + async def set_temperature(self, temperature: float): + logger.info( + "[OT TempModule %s] setting temperature to %.1f C", self.driver.opentrons_id, temperature + ) + ot_api.modules.temperature_module_set_temperature( + celsius=temperature, module_id=self.driver.opentrons_id + ) + + async def deactivate(self): + logger.info("[OT TempModule %s] deactivating", self.driver.opentrons_id) + ot_api.modules.temperature_module_deactivate(module_id=self.driver.opentrons_id) + + async def request_current_temperature(self) -> float: + modules = ot_api.modules.list_connected_modules() + for module in modules: + if module["id"] == self.driver.opentrons_id: + temp = cast(float, module["data"]["currentTemperature"]) + logger.info( + "[OT TempModule %s] read temperature: actual=%.1f C", self.driver.opentrons_id, temp + ) + return temp + logger.error("[OT TempModule %s] module not found", self.driver.opentrons_id) + raise RuntimeError(f"Module with id '{self.driver.opentrons_id}' not found") diff --git a/pylabrobot/opentrons/temperature_module/temperature_module.py b/pylabrobot/opentrons/temperature_module/temperature_module.py new file mode 100644 index 00000000000..c5f26627bb0 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/temperature_module.py @@ -0,0 +1,89 @@ +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureController, + TemperatureControllerBackend, +) +from pylabrobot.device import Device, Driver +from pylabrobot.resources import Coordinate, ItemizedResource, ResourceHolder +from pylabrobot.resources.opentrons.module import OTModule + +from .http_driver import ( + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend, +) +from .usb_driver import ( + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend, +) + + +class OpentronsTemperatureModuleV2(ResourceHolder, Device, OTModule): + """Opentrons Temperature Module v2. + + https://opentrons.com/products/modules/temperature/ + https://shop.opentrons.com/aluminum-block-set/ + + Example: + >>> from pylabrobot.opentrons.temperature_module import OpentronsTemperatureModuleV2 + >>> mod = OpentronsTemperatureModuleV2("temp_mod", serial_port="/dev/ttyACM0") + >>> await mod.setup() + >>> await mod.tc.set_temperature(37.0) + >>> await mod.tc.get_temperature() + 37.0 + """ + + def __init__( + self, + name: str, + opentrons_id: Optional[str] = None, + serial_port: Optional[str] = None, + child_location: Coordinate = Coordinate(0, 0, 80.1), + child: Optional[ItemizedResource] = None, + ): + """Create a new Opentrons Temperature Module v2. + + Args: + name: Name of the temperature module. + opentrons_id: Opentrons ID of the temperature module. Exactly one of + ``opentrons_id`` or ``serial_port`` must be provided. + serial_port: Serial port for USB communication. Exactly one of + ``opentrons_id`` or ``serial_port`` must be provided. + child_location: Location of the child resource relative to this module. + child: Optional child resource like a tube rack or well plate. + """ + if opentrons_id is None and serial_port is None: + raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + if opentrons_id is not None and serial_port is not None: + raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + + driver: Driver + tc_backend: TemperatureControllerBackend + if serial_port is not None: + driver = OpentronsTemperatureModuleUSBDriver(port=serial_port) + tc_backend = OpentronsTemperatureModuleUSBTemperatureBackend(driver=driver) + else: + assert opentrons_id is not None + driver = OpentronsTemperatureModuleDriver(opentrons_id=opentrons_id) + tc_backend = OpentronsTemperatureModuleTemperatureBackend(driver=driver) + + ResourceHolder.__init__( + self, + name=name, + size_x=193.5, + size_y=89.2, + size_z=84.0, + child_location=child_location, + category="temperature_controller", + model="temperatureModuleV2", + ) + Device.__init__(self, driver=driver) + self.driver = driver + self.tc = TemperatureController(backend=tc_backend) + self._capabilities = [self.tc] + + if child is not None: + self.assign_child_resource(child) + + def serialize(self) -> dict: + return {**ResourceHolder.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/opentrons/temperature_module/usb_driver.py b/pylabrobot/opentrons/temperature_module/usb_driver.py new file mode 100644 index 00000000000..9be0ce8daf3 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/usb_driver.py @@ -0,0 +1,101 @@ +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class OpentronsTemperatureModuleUSBDriver(Driver): + """Driver for the Opentrons Temperature Module v2 via direct USB serial. + + Owns the ``Serial`` connection and its lifecycle. + """ + + def __init__(self, port: str): + super().__init__() + self.port = port + self._serial: Optional[Serial] = None + + @property + def serial(self) -> Serial: + if self._serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + return self._serial + + async def setup(self, backend_params: Optional[BackendParams] = None): + self._serial = Serial( + human_readable_device_name="Opentrons Temperature Module", + port=self.port, + baudrate=115200, + timeout=3, + ) + await self._serial.setup() + logger.info("[OT TempModule USB %s] connected", self.port) + + async def stop(self): + if self._serial is not None: + await self._serial.stop() + self._serial = None + logger.info("[OT TempModule USB %s] disconnected", self.port) + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + + async def send_and_check(self, command: bytes): + """Send a command and verify the two-line 'ok' acknowledgement.""" + await self.serial.write(command) + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + logger.error("[OT TempModule USB %s] unexpected ack: %r %r", self.port, response1, response2) + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + + async def query_temperature(self) -> float: + """Send M105 and parse the temperature from the response.""" + await self.serial.write(b"M105\r\n") + response = await self.serial.readline() + if b"C" not in response: + raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") + + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + return float(response.strip().split(b"C:")[-1]) + + +class OpentronsTemperatureModuleUSBTemperatureBackend(TemperatureControllerBackend): + """Translates ``TemperatureControllerBackend`` into USB serial driver commands.""" + + def __init__(self, driver: OpentronsTemperatureModuleUSBDriver): + self.driver = driver + + @property + def supports_active_cooling(self) -> bool: + return True + + async def set_temperature(self, temperature: float): + logger.info( + "[OT TempModule USB %s] setting temperature to %.1f C", self.driver.port, temperature + ) + tmp_message = f"M104 S{temperature}\r\n" + await self.driver.send_and_check(tmp_message.encode("utf-8")) + + async def deactivate(self): + logger.info("[OT TempModule USB %s] deactivating", self.driver.port) + await self.driver.send_and_check(b"M18\r\n") + + async def request_current_temperature(self) -> float: + temp = await self.driver.query_temperature() + logger.info("[OT TempModule USB %s] read temperature: actual=%.1f C", self.driver.port, temp) + return temp 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/peeler.py b/pylabrobot/peeling/peeler.py deleted file mode 100644 index 4d7fba43b44..00000000000 --- a/pylabrobot/peeling/peeler.py +++ /dev/null @@ -1,17 +0,0 @@ -from pylabrobot.machines import Machine - -from .backend import PeelerBackend - - -class Peeler(Machine): - """A microplate peeler""" - - def __init__(self, backend: PeelerBackend): - super().__init__(backend=backend) - self.backend: PeelerBackend = backend - - async def peel(self, **backend_kwargs): - return await self.backend.peel(**backend_kwargs) - - async def restart(self, **backend_kwargs): - return await self.backend.restart(**backend_kwargs) 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/peeling/xpeel_backend.py b/pylabrobot/peeling/xpeel_backend.py deleted file mode 100644 index 8ea1b4a6983..00000000000 --- a/pylabrobot/peeling/xpeel_backend.py +++ /dev/null @@ -1,367 +0,0 @@ -import logging -import time -from dataclasses import dataclass -from typing import List, Literal, 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 -from pylabrobot.peeling.backend import PeelerBackend - - -class XPeelBackend(PeelerBackend): - """ - Client for the Azenta Life Sciences Automated Plate Seal Remover (XPeel) - RS-232 interface. All commands use lowercase ASCII, begin with '*' and end - with . - """ - - 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, logger=None, timeout=None): - if not HAS_SERIAL: - raise RuntimeError( - "pyserial is not installed. Install with: pip install pylabrobot[serial]. " - f"Import error: {_SERIAL_IMPORT_ERROR}" - ) - self.logger = logger or logging.getLogger(__name__) - self.port = port - self.response_timeout = timeout if timeout is not None else self.RESPONSE_TIMEOUT - - self._serial_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._serial_timeout, - write_timeout=self._serial_timeout, - rtscts=False, - ) - - async def setup(self): - await self.io.setup() - - async def stop(self): - await self.io.stop() - self.logger.info("Serial interface closed.") - - @classmethod - def describe_error(cls, code: int) -> str: - """ - Translate an XPeel error/status code to a human-readable message. - """ - 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): - """ - Parse a ready line like '*ready:06,01,00' to extract the primary error code - and its description. Returns a tuple (code: int, description: str). - """ - if not line.startswith("*ready:"): - return None - try: - # Expected format: *ready:CC,PP,TT (CC = error/condition code) - 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]: - """ - Send a command and collect responses until *ready (optional) or timeout. - - Returns a list of response lines (strings). - """ - full_cmd = cmd if cmd.endswith("\r\n") else f"{cmd}\r\n" - - if self.io is None: - raise RuntimeError("Serial interface not initialized; call setup() first.") - - self.logger.debug(f"Sending command: {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(f"Received: {display_line}") - print(f"Received: {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 get_status(self) -> Tuple[int, int, int]: - """ - Request instrument status; returns *ready:XX,XX,XX. - - The 'XX' fields refer to the three possible error codes from the error table that - occurred during the previous Automated Plate Peeler motion. The error codes - will remain in the ready response until another motion command is made or until a - restart command is sent. A single commanded action may accumulate up to three errors. - Since they are logged as they occur (left to right), any error code may appear in - any error field. Successful completion of any motion command will clear all three - error codes back to 00. - """ - self.logger.debug("Requesting status...") - resp = await self._send_command("*stat") - return tuple([int(x) for x in resp[-1].split(":")[1].split(",")]) # type: ignore - - async def get_version(self): - """Request firmware version.""" - self.logger.debug("Requesting firmware version...") - return await self._send_command("*version") - - async def reset(self): - """Request reset; instrument replies with ack then ready.""" - self.logger.debug("Requesting reset...") - return await self._send_command("*reset", expect_ack=True, wait_for_ready=True) - - async def restart(self): - """Request restart; instrument replies with ack then poweron/homing/ready.""" - self.logger.debug("Requesting restart...") - return await self._send_command("*restart", 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: Begin peel location in mm relative to default (0). Must be one of: -2, 0, 2, 4. - fast: Use fast speed if True, slow if False. - adhere_time: Adhere time (seconds). Must be one of: 2.5, 5.0, 7.5, 10.0. - """ - - self.logger.debug( - f"Running peel with begin_location={begin_location}, fast={fast}, adhere_time={adhere_time}..." - ) - - 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) - - if parameter_set not in range(1, 10): - raise ValueError("parameter_set must be in 1-9") - cmd = f"*xpeel:{parameter_set}{adhere_time}" - return await self._send_command( - cmd, - expect_ack=True, - wait_for_ready=True, - ) - - async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_detected"]: - """ - Check for seal presence; ready response encodes result. - - Response: - *ready:XX,00,00 (XX=04 if seal detected, XX=00 if no seal present, XX=06 if plate not detected) - """ - self.logger.debug("Checking 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 get_tape_remaining(self): - """ - Query remaining tape. - - Response: - *tape:SS,TT - - Where 'SS' times 10 is the number of “deseals” remaining on the supply spool and 'TT' times 10 is the - number of “deseals” that can be held on the space remaining on the take-up spool. - """ - self.logger.debug("Querying remaining tape...") - 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.""" - self.logger.debug(f"{'Enabling' if enabled else 'Disabling'} 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 get_seal_sensor_status(self): - """Get seal sensor threshold value (0-999)""" - self.logger.debug("Getting seal sensor threshold status...") - return await self._send_command("*sealstat", expect_ack=True, wait_for_ready=True) - - async def set_seal_threshold_upper(self, value: int): - """ - Set the seal detected threshold value(0-999). - Sensor readings higher than this value will be considered as "seal not detected" - """ - self.logger.debug(f"Setting upper seal sensor threshold to {value}...") - 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 seal detected threshold value(0-999). - Sensor readings higher than this value will be considered as "seal not detected" - """ - self.logger.debug(f"Setting lower seal sensor threshold to {value}...") - 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""" - self.logger.debug("Moving conveyor out...") - return await self._send_command( - "*moveout", - expect_ack=True, - wait_for_ready=True, - ) - - async def move_conveyor_in(self): - """Move conveyor in""" - self.logger.debug("Moving conveyor in...") - return await self._send_command( - "*movein", - expect_ack=True, - wait_for_ready=True, - ) - - async def move_elevator_down(self): - """Move elevator down""" - self.logger.debug("Moving elevator down...") - return await self._send_command( - "*movedown", - expect_ack=True, - wait_for_ready=True, - ) - - async def move_elevator_up(self): - """Move elevator up""" - self.logger.debug("Moving elevator up...") - return await self._send_command( - "*moveup", - expect_ack=True, - wait_for_ready=True, - ) - - async def advance_tape(self): - """Advance tape / move spool""" - self.logger.debug("Advancing tape/spool...") - return await self._send_command( - "*movespool", - expect_ack=True, - wait_for_ready=True, - ) 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 7e8366c6a58..e08734db787 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -1,52 +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, - 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/agilent/__init__.py b/pylabrobot/plate_reading/agilent/__init__.py deleted file mode 100644 index 59e960a561c..00000000000 --- a/pylabrobot/plate_reading/agilent/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .biotek_backend import BioTekPlateReaderBackend -from .biotek_cytation_backend import ( - Cytation5Backend, - Cytation5ImagingConfig, - CytationBackend, - CytationImagingConfig, -) -from .biotek_synergyh1_backend import SynergyH1Backend diff --git a/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py b/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py deleted file mode 100644 index 67d87b3f3b3..00000000000 --- a/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py +++ /dev/null @@ -1,953 +0,0 @@ -import asyncio -import atexit -import logging -import math -import re -import time -import warnings -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.resources import Plate - -try: - import PySpin # type: ignore - - # can be downloaded from https://www.teledynevisionsolutions.com/products/spinnaker-sdk/ - USE_PYSPIN = True -except ImportError as e: - USE_PYSPIN = False - _PYSPIN_IMPORT_ERROR = e - -from pylabrobot.plate_reading.standard import ( - Exposure, - FocalPosition, - Gain, - Image, - ImagingMode, - ImagingResult, - Objective, -) - -SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR = ( - PySpin.SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR if USE_PYSPIN else -1 -) -PixelFormat_Mono8 = PySpin.PixelFormat_Mono8 if USE_PYSPIN else -1 -SpinnakerException = PySpin.SpinnakerException if USE_PYSPIN else Exception - -logger = logging.getLogger(__name__) - - -@dataclass -class CytationImagingConfig: - camera_serial_number: Optional[str] = None - max_image_read_attempts: int = 50 - - # if not specified, these will be loaded from machine configuration (register with gen5.exe) - objectives: Optional[List[Optional[Objective]]] = None - filters: Optional[List[Optional[ImagingMode]]] = None - - -def retry(func, *args, **kwargs): - """Call func with retries and logging.""" - max_tries = 10 - delay = 0.1 - tries = 0 - while True: - try: - return func(*args, **kwargs) - except SpinnakerException as ex: - tries += 1 - if tries >= max_tries: - raise RuntimeError(f"Failed after {max_tries} tries") from ex - logger.warning( - "Retry %d/%d failed: %s", - tries, - max_tries, - str(ex), - ) - time.sleep(delay) - - -class CytationBackend(BioTekPlateReaderBackend, ImagerBackend): - """Backend for Agilent BioTek Cytation plate readers. - - The camera is interfaced using the Spinnaker SDK, and the camera used during development is the - Point Grey Research Inc. Blackfly BFLY-U3-23S6M. This uses a Sony IMX249 sensor. - """ - - def __init__( - self, - timeout: float = 20, - device_id: Optional[str] = None, - imaging_config: Optional[CytationImagingConfig] = None, - ) -> None: - super().__init__(timeout=timeout, device_id=device_id) - - self._spinnaker_system: Optional["PySpin.SystemPtr"] = None - self._cam: Optional["PySpin.CameraPtr"] = None - self.imaging_config = imaging_config or CytationImagingConfig() - 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 setup(self, use_cam: bool = False) -> None: - logger.info(f"{self.__class__.__name__} setting up") - - await super().setup() - - if use_cam: - try: - await self._set_up_camera() - except: - # if setting up the camera fails, we have to close the ftdi connection - # so that the user can try calling setup() again. - # if we don't close the ftdi connection here, it will be open until the - # python kernel is restarted. - try: - await self.stop() - except Exception: - pass - raise - - async def stop(self): - await super().stop() - - if self._acquiring: - self.stop_acquisition() - - logger.info(f"{self.__class__.__name__} stopping") - await self.stop_shaking() - await self.io.stop() - - self._stop_camera() - - self._objectives = None - self._filters = None - self._slow_mode = None - - self._clear_imaging_state() - - def _clear_imaging_state(self): - self._exposure = None - self._focal_height = None - self._gain = None - self._imaging_mode = None - self._row = None - self._column = None - self._pos_x, self._pos_y = 0, 0 - self._objective = None - - @property - def supports_heating(self): - return True - - @property - def supports_cooling(self): - return True - - async def _set_up_camera(self) -> None: - atexit.register(self._stop_camera) - - if not USE_PYSPIN: - raise RuntimeError( - "PySpin is not installed. Please follow the imaging setup instructions. " - f"Import error: {_PYSPIN_IMPORT_ERROR}" - ) - if self.imaging_config is None: - raise RuntimeError("Imaging configuration is not set.") - - logger.debug(f"{self.__class__.__name__} setting up camera") - - # -- Retrieve singleton reference to system object (Spinnaker) -- - self._spinnaker_system = PySpin.System.GetInstance() - version = self._spinnaker_system.GetLibraryVersion() - logger.debug( - f"{self.__class__.__name__} Library version: %d.%d.%d.%d", - version.major, - version.minor, - version.type, - version.build, - ) - - # -- Get the camera by serial number, or the first. -- - cam_list = self._spinnaker_system.GetCameras() - num_cameras = cam_list.GetSize() - logger.debug(f"{self.__class__.__name__} number of cameras detected: %d", num_cameras) - - for cam in cam_list: - info = self._get_device_info(cam) - serial_number = info["DeviceSerialNumber"] - logger.debug(f"{self.__class__.__name__} camera detected: %s", serial_number) - - if ( - self.imaging_config.camera_serial_number is not None - and serial_number == self.imaging_config.camera_serial_number - ): - self._cam = cam - logger.info(f"{self.__class__.__name__} using camera with serial number %s", serial_number) - break - else: # if no specific camera was found by serial number so use the first one - if num_cameras > 0: - self._cam = cam_list.GetByIndex(0) - logger.info( - f"{self.__class__.__name__} using first camera with serial number %s", - info["DeviceSerialNumber"], - ) - else: - logger.error(f"{self.__class__.__name__}: No cameras found") - self._cam = None - cam_list.Clear() - - if self._cam is None: - raise RuntimeError( - f"{self.__class__.__name__}: No camera found. Make sure the camera is connected and the serial " - "number is correct." - ) - - # -- Initialize camera -- - for _ in range(10): - try: - self._cam.Init() # SpinnakerException: Spinnaker: Could not read the XML URL [-1010] - break - except: # noqa - await asyncio.sleep(0.1) - pass - else: - raise RuntimeError( - "Failed to initialize camera. Make sure the camera is connected and the " - "Spinnaker SDK is installed correctly." - ) - nodemap = self._cam.GetNodeMap() - - # -- Configure trigger to be software -- - # This is needed for longer exposure times (otherwise 27.8ms is the maximum) - # 1. Set trigger selector to frame start - ptr_trigger_selector = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerSelector")) - if not PySpin.IsReadable(ptr_trigger_selector) or not PySpin.IsWritable(ptr_trigger_selector): - raise RuntimeError( - "unable to configure TriggerSelector (can't read or write TriggerSelector)" - ) - ptr_frame_start = PySpin.CEnumEntryPtr(ptr_trigger_selector.GetEntryByName("FrameStart")) - if not PySpin.IsReadable(ptr_frame_start): - raise RuntimeError("unable to configure TriggerSelector (can't read FrameStart)") - ptr_trigger_selector.SetIntValue(int(ptr_frame_start.GetNumericValue())) - - # 2. Set trigger source to software - ptr_trigger_source = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerSource")) - if not PySpin.IsReadable(ptr_trigger_source) or not PySpin.IsWritable(ptr_trigger_source): - raise RuntimeError("unable to configure TriggerSource (can't read or write TriggerSource)") - ptr_inference_ready = PySpin.CEnumEntryPtr(ptr_trigger_source.GetEntryByName("Software")) - if not PySpin.IsReadable(ptr_inference_ready): - raise RuntimeError("unable to configure TriggerSource (can't read Software)") - ptr_trigger_source.SetIntValue(int(ptr_inference_ready.GetNumericValue())) - - # 3. Set trigger mode to on - ptr_trigger_mode = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerMode")) - if not PySpin.IsReadable(ptr_trigger_mode) or not PySpin.IsWritable(ptr_trigger_mode): - raise RuntimeError("unable to configure TriggerMode (can't read or write TriggerMode)") - ptr_trigger_on = PySpin.CEnumEntryPtr(ptr_trigger_mode.GetEntryByName("On")) - if not PySpin.IsReadable(ptr_trigger_on): - raise RuntimeError("unable to query TriggerMode On") - ptr_trigger_mode.SetIntValue(int(ptr_trigger_on.GetNumericValue())) - - # "NOTE: Blackfly and Flea3 GEV cameras need 1 second delay after trigger mode is turned on" - await asyncio.sleep(1) - - # -- Load filter information -- - if self._filters is None: - await self._load_filters() - - # -- Load objective information -- - if self._objectives is None: - await self._load_objectives() - - @property - def objectives(self) -> List[Optional[Objective]]: - if self._objectives is None: - raise RuntimeError(f"{self.__class__.__name__}: Objectives are not set") - return self._objectives - - @property - def filters(self) -> List[Optional[ImagingMode]]: - if self._filters is None: - raise RuntimeError(f"{self.__class__.__name__}: Filters are not set") - return self._filters - - async def _load_filters(self): - self._filters = [] - for spot in range(1, 5): - configuration = await self.send_command("i", f"q{spot}") - assert configuration is not None - parts = configuration.decode().strip().split(" ") - if len(parts) == 1: - self._filters.append(None) - else: - cytation_code = int(parts[0]) - 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.PROPOIDIUM_IODIDE, - 1225103: ImagingMode.RFP, - 1225117: ImagingMode.RFP_CY5, - 1225115: ImagingMode.TAG_BFP, - 1225102: ImagingMode.TEXAS_RED, - 1225104: ImagingMode.YFP, - } - if cytation_code not in cytation_code2imaging_mode: - self._filters.append(None) - else: - self._filters.append(cytation_code2imaging_mode[cytation_code]) - - async def _load_objectives(self): - self._objectives = [] - if self.version.startswith("1"): - for spot in [1, 2]: - configuration = await self.send_command("i", f"o{spot}") - 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", - } - if configuration is None: - raise RuntimeError("Failed to load objective configuration") - # TODO: loading when no objective is set. I believe it's four 0s. - middle_part = re.split(r"\s+", configuration.rstrip(b"\x03").decode("utf-8"))[1] - # not the real part number, but it's what's used in the xml files. eg "UPLFLN" - if middle_part == "0000": - self._objectives.append(None) - else: - part_number = "".join([weird_encoding[x] for x in bytes.fromhex(middle_part)]) - 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.append(part_number2objective[part_number.lower()]) - elif self.version.startswith("2"): - for spot in range(1, 7): - # +1 for some reason, eg first is h2 - configuration = await self.send_command("i", f"h{spot + 1}") - assert configuration is not None - if configuration.startswith(b"****"): - self._objectives.append(None) - else: - annulus_part_number = int(configuration.decode("latin").strip().split(" ")[0]) - annulus_part_number2objective = { - 1320520: Objective.O_4X_PL_FL_Phase, - 1320521: Objective.O_20X_PL_FL_Phase, - 1322026: Objective.O_40X_PL_FL_Phase, - } - self._objectives.append(annulus_part_number2objective[annulus_part_number]) - else: - raise RuntimeError(f"{self.__class__.__name__}: Unsupported version: {self.version}") - - def _stop_camera(self) -> None: - if self._cam is not None: - if self._acquiring: - self.stop_acquisition() - - self._reset_trigger() - - self._cam.DeInit() - self._cam = None - if self._spinnaker_system is not None: - self._spinnaker_system.ReleaseInstance() - - def _reset_trigger(self): - if self._cam is None: - return - - # adopted from example - try: - nodemap = self._cam.GetNodeMap() - node_trigger_mode = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerMode")) - if not PySpin.IsReadable(node_trigger_mode) or not PySpin.IsWritable(node_trigger_mode): - return - - node_trigger_mode_off = node_trigger_mode.GetEntryByName("Off") - if not PySpin.IsReadable(node_trigger_mode_off): - return - - node_trigger_mode.SetIntValue(node_trigger_mode_off.GetValue()) - except PySpin.SpinnakerException: - pass - - def _get_device_info(self, cam): - """Get device info for cameras.""" - # should have keys: - # - DeviceID - # - DeviceSerialNumber - # - DeviceUserID - # - DeviceVendorName - # - DeviceModelName - # - DeviceVersion - # - DeviceBootloaderVersion - # - DeviceType - # - DeviceDisplayName - # - DeviceAccessStatus - # - DeviceDriverVersion - # - DeviceIsUpdater - # - DeviceInstanceId - # - DeviceLocation - # - DeviceCurrentSpeed - # - DeviceU3VProtocol - # - DevicePortId - # - GenICamXMLLocation - # - GenICamXMLPath - # - GUIXMLLocation - # - GUIXMLPath - - device_info = {} - - nodemap = cam.GetTLDeviceNodeMap() - node_device_information = PySpin.CCategoryPtr(nodemap.GetNode("DeviceInformation")) - if not PySpin.IsReadable(node_device_information): - raise RuntimeError("Device control information not readable.") - - features = node_device_information.GetFeatures() - for feature in features: - node_feature = PySpin.CValuePtr(feature) - node_feature_name = node_feature.GetName() - try: - node_feature_value = node_feature.ToString() if PySpin.IsReadable(node_feature) else None - except Exception as e: - raise RuntimeError( - f"Got an error while reading feature {node_feature_name}. " - "Is the cytation in use by another notebook? " - f"Error: {str(e)}" - ) from e - device_info[node_feature_name] = node_feature_value - - return device_info - - async def close(self, plate: Optional[Plate], slow: bool = False): - await super().close(plate, slow) - self._clear_imaging_state() - - def start_acquisition(self): - if self._cam is None: - raise RuntimeError(f"{self.__class__.__name__}: Camera is not initialized.") - if self._acquiring: - return - retry(self._cam.BeginAcquisition) - self._acquiring = True - - def stop_acquisition(self): - if self._cam is None: - raise RuntimeError(f"{self.__class__.__name__}: Camera is not initialized.") - if not self._acquiring: - return - retry(self._cam.EndAcquisition) - self._acquiring = False - - async def led_on(self, intensity: int = 10): - if not 1 <= intensity <= 10: - raise ValueError("intensity must be between 1 and 10") - intensity_str = str(intensity).zfill(2) - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Run set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command("i", f"L0{imaging_mode_code}{intensity_str}") - - async def led_off(self): - await self.send_command("i", "L0001") - - async def set_focus(self, focal_position: FocalPosition): - """focus position in mm""" - - if focal_position == "machine-auto": - raise ValueError( - "focal_position cannot be 'machine-auto'. Use the PLR Imager universal autofocus instead." - ) - - if focal_position == self._focal_height: - logger.debug("Focus position is already set to %s", focal_position) - return - - # There is a difference between the number in the program and the number sent to the machine, - # which is modelled using the following linear relation. R^2=0.999999999 - # convert from mm to um - slope, intercept = (10.637991436186072, 1.0243013203461762) - focus_integer = int(focal_position + intercept + slope * focal_position * 1000) - focus_str = str(focus_integer).zfill(5) - - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Run set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command("i", f"F{imaging_mode_code}0{focus_str}") - - self._focal_height = focal_position - - async def set_position(self, x: float, y: float): - """ - Args: - x: in mm from the center of the selected well - y: in mm from the center of the selected well - """ - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Run set_imaging_mode() first.") - - if x == self._pos_x and y == self._pos_y: - logger.debug("Position is already set to (%s, %s)", x, y) - return - - # firmware is in (10/0.984 (10/0.984))um units. plr is mm. To convert - x_str, y_str = ( - str(round(x * 100 * 0.984)).zfill(6), - 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. Run select() first.") - row_str, column_str = str(self._row).zfill(2), str(self._column).zfill(2) - - if self._objective is None: - raise ValueError("Objective not set. Run set_objective() first.") - objective_code = self._objective_code(self._objective) - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Run set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command( - "Y", f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}{y_str}{x_str}" - ) - - relative_x, relative_y = x - (self._pos_x or 0), y - (self._pos_y or 0) - if relative_x != 0: - relative_x_str = str(round(relative_x * 100 * 0.984)).zfill(6) - await self.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.send_command("Y", f"O01{relative_y_str}") - - self._pos_x, self._pos_y = x, y - await asyncio.sleep(0.1) - - async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]): - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - - if self._cam.ExposureAuto.GetAccessMode() != PySpin.RW: - raise RuntimeError("unable to write ExposureAuto") - - retry( - self._cam.ExposureAuto.SetValue, - { - "off": PySpin.ExposureAuto_Off, - "once": PySpin.ExposureAuto_Once, - "continuous": PySpin.ExposureAuto_Continuous, - }[auto_exposure], - ) - - async def set_exposure(self, exposure: Exposure): - """exposure (integration time) in ms, or "machine-auto" """ - - if exposure == self._exposure: - logger.debug("Exposure time is already set to %s", exposure) - return - - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - - # either set auto exposure to continuous, or turn off - if isinstance(exposure, str): - if exposure == "machine-auto": - await self.set_auto_exposure("continuous") - self._exposure = "machine-auto" - return - raise ValueError("exposure must be a number or 'auto'") - retry(self._cam.ExposureAuto.SetValue, PySpin.ExposureAuto_Off) - - # set exposure time (in microseconds) - if self._cam.ExposureTime.GetAccessMode() != PySpin.RW: - raise RuntimeError("unable to write ExposureTime") - exposure_us = int(exposure * 1000) - min_et = retry(self._cam.ExposureTime.GetMin) - if exposure_us < min_et: - raise ValueError(f"exposure must be >= {min_et}") - max_et = retry(self._cam.ExposureTime.GetMax) - if exposure_us > max_et: - raise ValueError(f"exposure must be <= {max_et}") - retry(self._cam.ExposureTime.SetValue, exposure_us) - self._exposure = exposure - - async def select(self, row: int, column: int): - if row == self._row and column == self._column: - logger.debug("Already selected %s, %s", row, column) - return - row_str, column_str = str(row).zfill(2), str(column).zfill(2) - await self.send_command("Y", f"W6{row_str}{column_str}") - self._row, self._column = row, column - self._pos_x, self._pos_y = None, None - await self.set_position(0, 0) - - async def set_gain(self, gain: Gain): - """gain of unknown units, or "machine-auto" """ - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - - if gain == self._gain: - logger.debug("Gain is already set to %s", gain) - return - - if not (gain == "machine-auto" or 0 <= gain <= 30): - raise ValueError("gain must be between 0 and 30 (inclusive), or 'auto'") - - nodemap = self._cam.GetNodeMap() - - # set/disable automatic gain - node_gain_auto = PySpin.CEnumerationPtr(nodemap.GetNode("GainAuto")) - if not PySpin.IsReadable(node_gain_auto) or not PySpin.IsWritable(node_gain_auto): - raise RuntimeError("unable to set automatic gain") - node = ( - PySpin.CEnumEntryPtr(node_gain_auto.GetEntryByName("Continuous")) - if gain == "machine-auto" - else PySpin.CEnumEntryPtr(node_gain_auto.GetEntryByName("Off")) - ) - if not PySpin.IsReadable(node): - raise RuntimeError("unable to set automatic gain (enum entry retrieval)") - node_gain_auto.SetIntValue(node.GetValue()) - - if not gain == "machine-auto": - node_gain = PySpin.CFloatPtr(nodemap.GetNode("Gain")) - if ( - not PySpin.IsReadable(node_gain) - or not PySpin.IsWritable(node_gain) - or node_gain.GetMax() == 0 - ): - raise RuntimeError("unable to set gain") - min_gain = node_gain.GetMin() - if gain < min_gain: - raise ValueError(f"gain must be >= {min_gain}") - max_gain = node_gain.GetMax() - if gain > max_gain: - raise ValueError(f"gain must be <= {max_gain}") - node_gain.SetValue(gain) - - self._gain = gain - - def _imaging_mode_code(self, mode: ImagingMode) -> int: - if mode == ImagingMode.BRIGHTFIELD or mode == ImagingMode.PHASE_CONTRAST: - return 5 - return self.filters.index(mode) + 1 - - def _objective_code(self, objective: Objective) -> int: - return self.objectives.index(objective) + 1 - - async def set_objective(self, objective: Objective): - if objective == self._objective: - logger.debug("Objective is already set to %s", objective) - return - - if self.imaging_config is None: - raise RuntimeError("Need to set imaging_config first") - - objective_code = self._objective_code(objective) - - if self.version.startswith("1"): - await self.send_command("Y", f"P0d{objective_code:02}", timeout=60) - else: - await self.send_command("Y", f"P0e{objective_code:02}", timeout=60) - - self._objective = objective - self._imaging_mode = None - - async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int): - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - - if mode == self._imaging_mode: - logger.debug("Imaging mode is already set to %s", mode) - await self.led_on(intensity=led_intensity) - return - - if mode == ImagingMode.COLOR_BRIGHTFIELD: - # color brightfield will quickly switch through different filters, 05, 06, 07, 08 - # it sometimes calls (i, l{4,5,6,7}) before switching to the next filter. unclear. - raise NotImplementedError("Color brightfield imaging not implemented yet") - - await self.led_off() - - if self.imaging_config is None: - raise RuntimeError("Need to set imaging_config first") - - filter_index = self._imaging_mode_code(mode) - - if self.version.startswith("1"): - if mode == ImagingMode.PHASE_CONTRAST: - raise NotImplementedError("Phase contrast imaging not implemented yet on Cytation1") - elif mode == ImagingMode.BRIGHTFIELD: - await self.send_command("Y", "P0c05") - await self.send_command("Y", "P0f02") - else: - await self.send_command("Y", f"P0c{filter_index:02}") - await self.send_command("Y", "P0f01") - else: - if mode == ImagingMode.PHASE_CONTRAST: - await self.send_command("Y", "P1120") - await self.send_command("Y", "P0d05") - await self.send_command("Y", "P1002") - elif mode == ImagingMode.BRIGHTFIELD: - await self.send_command("Y", "P1101") - await self.send_command("Y", "P0d05") - await self.send_command("Y", "P1002") - else: - await self.send_command("Y", "P1101") - await self.send_command("Y", f"P0d{filter_index:02}") - await self.send_command("Y", "P1001") - - # Turn led on in the new mode - self._imaging_mode = mode - await self.led_on(intensity=led_intensity) - - async def _acquire_image( - self, - color_processing_algorithm: int = SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR, - pixel_format: int = PixelFormat_Mono8, - ) -> Image: - assert self._cam is not None - nodemap = self._cam.GetNodeMap() - - assert self.imaging_config is not None, "Need to set imaging_config first" - - num_tries = 0 - while num_tries < self.imaging_config.max_image_read_attempts: - node_softwaretrigger_cmd = PySpin.CCommandPtr(nodemap.GetNode("TriggerSoftware")) - if not PySpin.IsWritable(node_softwaretrigger_cmd): - raise RuntimeError("unable to execute software trigger") - - try: - node_softwaretrigger_cmd.Execute() - timeout = int(self._cam.ExposureTime.GetValue() / 1000 + 1000) # from example - image_result = self._cam.GetNextImage(timeout) - if not image_result.IsIncomplete(): - processor = PySpin.ImageProcessor() - processor.SetColorProcessing(color_processing_algorithm) - image_converted = processor.Convert(image_result, pixel_format) - image_result.Release() - return image_converted.GetNDArray() # type: ignore - except SpinnakerException as e: - # the image is not ready yet, try again - logger.warning("Failed to get image: %s", e) - self.stop_acquisition() - self.start_acquisition() - if "[-1011]" in str(e): - logger.warning( - "[-1011] error might occur when the camera is plugged into a USB hub that does not have enough throughput." - ) - - num_tries += 1 - await asyncio.sleep(0.3) - raise TimeoutError("max_image_read_attempts reached") - - 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, - color_processing_algorithm: int = SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR, - pixel_format: int = PixelFormat_Mono8, - auto_stop_acquisition=True, - ) -> ImagingResult: - """Capture image using the microscope - - speed: 211 ms ± 331 μs per loop (mean ± std. dev. of 7 runs, 10 loops each) - - Args: - exposure_time: exposure time in ms, or `"machine-auto"` - focal_height: focal height in mm, or `"machine-auto"` - coverage: coverage of the well, either `"full"` or a tuple of `(num_rows, num_columns)`. - Around `center_position`. - center_position: center position of the well, in mm from the center of the selected well. If - `None`, the center of the selected well is used (eg (0, 0) offset). If `coverage` is - specified, this is the center of the coverage area. - color_processing_algorithm: color processing algorithm. See - PySpin.SPINNAKER_COLOR_PROCESSING_ALGORITHM_* - pixel_format: pixel format. See PySpin.PixelFormat_* - """ - - assert overlap is None, "not implemented yet" - - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - - await self.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]: - # "wide fov" is an option in gen5.exe, but in reality it takes the same pictures. So we just - # simply take the wide fov option. - # um to mm (plr unit) - if magnification == 4: - return (3474 / 1000, 3474 / 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 - - # Get positions, centered around enter_position - if center_position is None: - center_position = (0, 0) - # Going in a snake pattern is not faster (strangely) - 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() - images.append( - await self._acquire_image( - color_processing_algorithm=color_processing_algorithm, pixel_format=pixel_format - ) - ) - t1 = time.time() - logger.debug( - "[cytation5] acquired image in %.2f seconds at position", - t1 - t0, - ) - finally: - await self.led_off() - if auto_stop_acquisition: - self.stop_acquisition() - - exposure_ms = float(self._cam.ExposureTime.GetValue()) / 1000 - assert self._focal_height is not None, "Focal height not set. Run set_focus() first." - focal_height_val = float(self._focal_height) - - return ImagingResult(images=images, exposure_time=exposure_ms, focal_height=focal_height_val) - - -class Cytation5ImagingConfig(CytationImagingConfig): - def __init__(self, *args, **kwargs): - warnings.warn( - "`Cytation5ImagingConfig` is deprecated. Please use `CytationImagingConfig` instead. ", - FutureWarning, - ) - super().__init__(*args, **kwargs) - - -class Cytation5Backend(CytationBackend): - def __init__(self, *args, **kwargs): - warnings.warn( - "`Cytation5Backend` is deprecated. Please use `CytationBackend` instead. ", - FutureWarning, - ) - super().__init__(*args, **kwargs) diff --git a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py b/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py deleted file mode 100644 index 5036bb33b83..00000000000 --- a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py +++ /dev/null @@ -1,88 +0,0 @@ -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.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend - -logger = logging.getLogger(__name__) - - -class SynergyH1Backend(BioTekPlateReaderBackend): - """Backend for Agilent BioTek Synergy H1 plate readers.""" - - @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/plate_reading/biotek_cytation_backend.py b/pylabrobot/plate_reading/biotek_cytation_backend.py deleted file mode 100644 index c89a11cbf88..00000000000 --- a/pylabrobot/plate_reading/biotek_cytation_backend.py +++ /dev/null @@ -1,13 +0,0 @@ -import warnings - -from .agilent.biotek_cytation_backend import ( - Cytation5Backend, # noqa: F401 - Cytation5ImagingConfig, # noqa: F401 - CytationBackend, # noqa: F401 - CytationImagingConfig, # 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/bmg_labtech/clario_star_backend.py b/pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py deleted file mode 100644 index f3459aa138e..00000000000 --- a/pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py +++ /dev/null @@ -1,434 +0,0 @@ -import asyncio -import logging -import math -import struct -import sys -import time -from typing import Dict, List, Optional, Tuple, Union - -from pylabrobot import utils -from pylabrobot.io.ftdi import FTDI -from pylabrobot.resources.plate import Plate -from pylabrobot.resources.well import Well - -from ..backend import PlateReaderBackend - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - -logger = logging.getLogger("pylabrobot") - - -class CLARIOstarBackend(PlateReaderBackend): - """A plate reader backend for the Clario star. Note that this is not a complete implementation - and many commands and parameters are not implemented yet.""" - - def __init__(self, device_id: Optional[str] = None): - self.io = FTDI( - human_readable_device_name="BMG CLARIOstar", device_id=device_id, vid=0x0403, pid=0xBB68 - ) - - async def setup(self): - await self.io.setup() - await self.io.set_baudrate(125000) - await self.io.set_line_property(8, 0, 0) # 8N1 - await self.io.set_latency_timer(2) - - await self.initialize() - await self.request_eeprom_data() - - async def stop(self): - await self.io.stop() - - async def get_stat(self): - stat = await self.io.poll_modem_status() - return hex(stat) - - async def read_resp(self, timeout=20) -> bytes: - """Read a response from the plate reader. If the timeout is reached, return the data that has - been read so far.""" - - d = b"" - last_read = b"" - end_byte_found = False - t = time.time() - - # Commands are terminated with 0x0d, but this value may also occur as a part of the response. - # Therefore, we read until we read a 0x0d, but if that's the last byte we read in a full packet, - # we keep reading for at least one more cycle. We only check the timeout if the last read was - # unsuccessful (i.e. keep reading if we are still getting data). - while True: - last_read = await self.io.read(25) # 25 is max length observed in pcap - if len(last_read) > 0: - d += last_read - end_byte_found = d[-1] == 0x0D - if ( - len(last_read) < 25 and end_byte_found - ): # if we read less than 25 bytes, we're at the end - break - else: - # If we didn't read any data, check if the last read ended in an end byte. If so, we're done - if end_byte_found: - break - - # Check if we've timed out. - if time.time() - t > timeout: - logger.warning("timed out reading response") - break - - # If we read data, we don't wait and immediately try to read more. - await asyncio.sleep(0.0001) - - logger.debug("read %s", d.hex()) - - return d - - async def send(self, cmd: Union[bytearray, bytes], read_timeout=20): - """Send a command to the plate reader and return the response.""" - - checksum = (sum(cmd) & 0xFFFF).to_bytes(2, byteorder="big") - cmd = cmd + checksum + b"\x0d" - - logger.debug("sending %s", cmd.hex()) - - w = await self.io.write(cmd) - - logger.debug("wrote %s bytes", w) - - assert w == len(cmd) - - resp = await self.read_resp(timeout=read_timeout) - return resp - - async def _wait_for_ready_and_return(self, ret, timeout=150): - """Wait for the plate reader to be ready and return the response.""" - last_status = None - t = time.time() - while time.time() - t < timeout: - await asyncio.sleep(0.1) - - command_status = await self.read_command_status() - - if len(command_status) != 24: - logger.warning( - "unexpected response %s. I think a command status response is always 24 bytes", - command_status, - ) - continue - - if command_status != last_status: - logger.info("status changed %s", command_status.hex()) - last_status = command_status - else: - continue - - if command_status[2] != 0x18 or command_status[3] != 0x0C or command_status[4] != 0x01: - logger.warning( - "unexpected response %s. I think 18 0c 01 indicates a command status response", - command_status, - ) - - if command_status[5] not in { - 0x25, - 0x05, - }: # 25 is busy, 05 is ready. probably. - logger.warning("unexpected response %s.", command_status) - - if command_status[5] == 0x05: - logger.debug("status is ready") - return ret - - async def read_command_status(self): - status = await self.send(b"\x02\x00\x09\x0c\x80\x00") - return status - - async def initialize(self): - command_response = await self.send(b"\x02\x00\x0d\x0c\x01\x00\x00\x10\x02\x00") - return await self._wait_for_ready_and_return(command_response) - - async def request_eeprom_data(self): - eeprom_response = await self.send(b"\x02\x00\x0f\x0c\x05\x07\x00\x00\x00\x00\x00\x00") - return await self._wait_for_ready_and_return(eeprom_response) - - async def open(self): - open_response = await self.send(b"\x02\x00\x0e\x0c\x03\x01\x00\x00\x00\x00\x00") - return await self._wait_for_ready_and_return(open_response) - - async def close(self, plate: Optional[Plate] = None): - close_response = await self.send(b"\x02\x00\x0e\x0c\x03\x00\x00\x00\x00\x00\x00") - return await self._wait_for_ready_and_return(close_response) - - async def _mp_and_focus_height_value(self): - mp_and_focus_height_value_response = await self.send( - b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00" + b"\x00\x00" - ) - return await self._wait_for_ready_and_return(mp_and_focus_height_value_response) - - def _plate_bytes(self, plate: Plate) -> bytes: - """Encode the plate geometry into the binary format the CLARIOstar expects. - - Returns a 62-byte sequence: plate dimensions (12 bytes), column/row counts (2 bytes), - and a 384-bit well mask (48 bytes). - """ - - def float_to_bytes(f: float) -> bytes: - return round(f * 100).to_bytes(2, byteorder="big") - - plate_length = plate.get_absolute_size_x() - plate_width = plate.get_absolute_size_y() - - well_0 = plate.get_well(0) - assert well_0.location is not None, "Well 0 must be assigned to a plate" - plate_x1 = well_0.location.x + well_0.center().x - plate_y1 = plate_width - (well_0.location.y + well_0.center().y) - plate_xn = plate_length - plate_x1 - plate_yn = plate_width - plate_y1 - - plate_cols = plate.num_items_x - plate_rows = plate.num_items_y - - # 384-bit mask: first num_items bits set, rest zero - wells = ([1] * plate.num_items) + ([0] * (384 - plate.num_items)) - well_mask: int = sum(b << i for i, b in enumerate(wells[::-1])) - wells_bytes = well_mask.to_bytes(48, "big") - - return ( - float_to_bytes(plate_length) - + float_to_bytes(plate_width) - + float_to_bytes(plate_x1) - + float_to_bytes(plate_y1) - + float_to_bytes(plate_xn) - + float_to_bytes(plate_yn) - + plate_cols.to_bytes(1, byteorder="big") - + plate_rows.to_bytes(1, byteorder="big") - + wells_bytes - ) - - async def _run_luminescence(self, focal_height: float, plate: Plate): - """Run a plate reader luminescence run.""" - - assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" - - focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") - plate_bytes = self._plate_bytes(plate) - - payload = ( - b"\x04" + plate_bytes + b"\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" - b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01\x00" - b"\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01" - b"\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" - ) - message_size = (len(payload) + 7).to_bytes(2, byteorder="big") - cmd = b"\x02" + message_size + b"\x0c" + payload - run_response = await self.send(cmd) - - # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. - last_status = None - while True: - await asyncio.sleep(0.1) - - command_status = await self.read_command_status() - - if command_status != last_status: - last_status = command_status - logger.info("status changed %s", command_status) - continue - - if command_status == bytes( - b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" - b"\x00\x00\x00\xc0\x00\x01\x46\x0d" - ): - return run_response - - async def _run_absorbance(self, wavelength: float, plate: Plate): - """Run a plate reader absorbance run.""" - wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") - plate_bytes = self._plate_bytes(plate) - - payload = ( - b"\x04" + plate_bytes + b"\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27\x0f\x27" - b"\x0f\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" - b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00" - ) - message_size = (len(payload) + 7).to_bytes(2, byteorder="big") - cmd = b"\x02" + message_size + b"\x0c" + payload - run_response = await self.send(cmd) - - # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. - last_status = None - while True: - await asyncio.sleep(0.1) - - command_status = await self.read_command_status() - - if command_status != last_status: - last_status = command_status - logger.info("status changed %s", command_status) - continue - - if command_status == bytes( - b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" - b"\x00\x00\x00\xc0\x00\x01\x46\x0d" - ): - return run_response - - async def _read_order_values(self): - return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") - - async def _status_hw(self): - status_hw_response = await self.send(b"\x02\x00\x09\x0c\x81\x00") - return await self._wait_for_ready_and_return(status_hw_response) - - async def _get_measurement_values(self): - return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float = 13 - ) -> List[Dict]: - """Read luminescence values from the plate reader.""" - if wells != plate.get_all_items(): - raise NotImplementedError("Only full plate reads are supported for now.") - - await self._mp_and_focus_height_value() - - await self._run_luminescence(focal_height=focal_height, plate=plate) - - await self._read_order_values() - - await self._status_hw() - - vals = await self._get_measurement_values() - - # All values are 32 bit integers. The header is variable length, so we need to find the - # start of the data. In the future, when we understand the protocol better, this can be - # replaced with a more robust solution. - num_wells = plate.num_items - start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") - data = list(vals)[start_idx : start_idx + num_wells * 4] - - # group bytes by 4 - int_bytes = [data[i : i + 4] for i in range(0, len(data), 4)] - - # convert to int - ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] - - # for backend conformity, convert to float, and reshape to 2d array - floats: List[List[Optional[float]]] = utils.reshape_2d( - [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) - ) - - return [ - { - "data": floats, - "temperature": float("nan"), # Temperature not available - "time": time.time(), - } - ] - - async def read_absorbance( - self, - plate: Plate, - wells: List[Well], - wavelength: int, - report: Literal["OD", "transmittance"] = "OD", - ) -> List[Dict]: - """Read absorbance values from the device. - - Args: - wavelength: wavelength to read absorbance at, in nanometers. - report: whether to report absorbance as optical depth (OD) or transmittance. Transmittance is - used interchangeably with "transmission" in the CLARIOStar software and documentation. - - Returns: - A list containing a single dictionary, where the key is (wavelength, 0) and the value is - another dictionary containing the data, temperature, and time. - """ - - if wells != plate.get_all_items(): - raise NotImplementedError("Only full plate reads are supported for now.") - - await self._mp_and_focus_height_value() - - await self._run_absorbance(wavelength=wavelength, plate=plate) - - await self._read_order_values() - - await self._status_hw() - - vals = await self._get_measurement_values() - num_wells = plate.num_items - div = b"\x00" * 6 - start_idx = vals.index(div) + len(div) - chromatic_data = vals[start_idx : start_idx + num_wells * 4] - ref_data = vals[start_idx + num_wells * 4 : start_idx + (num_wells * 2) * 4] - chromatic_bytes = [bytes(chromatic_data[i : i + 4]) for i in range(0, len(chromatic_data), 4)] - ref_bytes = [bytes(ref_data[i : i + 4]) for i in range(0, len(ref_data), 4)] - chromatic_reading = [struct.unpack(">i", x)[0] for x in chromatic_bytes] - reference_reading = [struct.unpack(">i", x)[0] for x in ref_bytes] - - # c100 is the value of the chromatic at 100% intensity - # c0 is the value of the chromatic at 0% intensity (black reading) - # r100 is the value of the reference at 100% intensity - # r0 is the value of the reference at 0% intensity (black reading) - after_values_idx = start_idx + (num_wells * 2) * 4 - c100, c0, r100, r0 = struct.unpack(">iiii", vals[after_values_idx : after_values_idx + 4 * 4]) - - # a bit much, but numpy should not be a dependency - real_chromatic_reading = [] - for cr in chromatic_reading: - real_chromatic_reading.append((cr - c0) / c100) - real_reference_reading = [] - for rr in reference_reading: - real_reference_reading.append((rr - r0) / r100) - - transmittance: List[Optional[float]] = [] - for rcr, rrr in zip(real_chromatic_reading, real_reference_reading): - transmittance.append(rcr / rrr * 100) - - data: List[List[Optional[float]]] - if report == "OD": - od: List[Optional[float]] = [] - for t in transmittance: - od.append(math.log10(100 / t) if t is not None and t > 0 else None) - data = utils.reshape_2d(od, (plate.num_items_y, plate.num_items_x)) - elif report == "transmittance": - data = utils.reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) - else: - raise ValueError(f"Invalid report type: {report}") - - return [ - { - "wavelength": wavelength, - "data": data, - "temperature": float("nan"), # Temperature not available - "time": time.time(), - } - ] - - async def read_fluorescence( - self, - plate: Plate, - wells: List[Well], - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[Dict[Tuple[int, int], Dict]]: - raise NotImplementedError("Not implemented yet") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class CLARIOStar: - def __init__(self, *args, **kwargs): - raise RuntimeError("`CLARIOStar` is deprecated. Please use `CLARIOStarBackend` instead.") - - -class CLARIOStarBackend: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`CLARIOStarBackend` (capital 'S') is deprecated. Please use `CLARIOstarBackend` instead." - ) diff --git a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py b/pylabrobot/plate_reading/byonoy/byonoy_a96a.py deleted file mode 100644 index f30252e501d..00000000000 --- a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py +++ /dev/null @@ -1,188 +0,0 @@ -from typing import Optional, Tuple - -from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend -from pylabrobot.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 - - -def byonoy_sbs_adapter(name: str) -> ResourceHolder: - """Create a Byonoy SBS adapter `ResourceHolder`. - - This helper returns a `ResourceHolder` describing the physical footprint of the - Byonoy SBS adapter and the default coordinate transform from the adapter frame - to its child frame. - - The adapter is modeled as a cuboid with fixed outer dimensions. - `child_location` encodes the child-frame origin offset assuming the SBS-adapter - is symmetrically centered ("cc") relative to the detection_unit "cc" alignment reference. - """ - 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, - ), - ) - - -class _ByonoyAbsorbanceReaderPlateHolder(PlateHolder): - """Custom plate holder that checks if the reader sits on the parent base. - This check is used to prevent crashes (moving plate onto holder while reader 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, # standard SBS footprint - 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("ByonoyBase 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( - "ByonoyBase does not support assigning child resources directly. " - "Use the plate_holder or illumination_unit_holder to assign plates and the illumination unit, respectively." - ) - - -class ByonoyAbsorbance96Automate(PlateReader, ByonoyAbsorbanceBaseUnit): - def __init__(self, name: str): - ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") - PlateReader.__init__( - self, - name=name + "_reader", - size_x=138, - size_y=95.7, - size_z=0, - backend=ByonoyAbsorbance96AutomateBackend(), - ) - - -def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96Automate: - """Create a Byonoy A96A detection unit `PlateReader`. - - The detection unit is modeled as a fixed-size rectangular prism. - """ - - return ByonoyAbsorbance96Automate(name=name) - - -def byonoy_a96a_parking_unit(name: str) -> ByonoyAbsorbanceBaseUnit: - """Create a Byonoy A96A detection unit holder.""" - - return ByonoyAbsorbanceBaseUnit(name=name) - - -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(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96Automate, Resource]: - """Creates a ByonoyBase and a PlateReader instance.""" - 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/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/plate_reading/byonoy/byonoy_backend.py deleted file mode 100644 index ca2ae684cf3..00000000000 --- a/pylabrobot/plate_reading/byonoy/byonoy_backend.py +++ /dev/null @@ -1,401 +0,0 @@ -import abc -import asyncio -import enum -import threading -import time -from typing import Dict, List, Optional - -from pylabrobot.io.binary import Reader, Writer -from pylabrobot.io.hid import HID -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.resources import Plate, Well -from pylabrobot.utils.list import reshape_2d - - -class _ByonoyDevice(enum.Enum): - ABSORBANCE_96 = enum.auto() - LUMINESCENCE_96 = enum.auto() - - -class _ByonoyBase(PlateReaderBackend, metaclass=abc.ABCMeta): - """Base backend for Byonoy plate readers using HID communication. - Provides common functionality for different Byonoy machine types. - """ - - def __init__(self, pid: int, device_type: _ByonoyDevice) -> None: - self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) - self._background_thread: Optional[threading.Thread] = None - self._stop_background = threading.Event() - self._ping_interval = 1.0 # Send ping every second - self._sending_pings = False # Whether to actively send pings - self._device_type = device_type - - async def setup(self) -> None: - """Set up the plate reader. This should be called before any other methods.""" - - await self.io.setup() - - # Start background keep alive messages - self._stop_background.clear() - self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True) - self._background_thread.start() - - async def stop(self) -> None: - """Close all connections to the plate reader and make sure setup() can be called again.""" - - # Stop background keep alive messages - self._stop_background.set() - if self._background_thread and self._background_thread.is_alive(): - self._background_thread.join(timeout=2.0) - - await self.io.stop() - - 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 # pad to 64 bytes - 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) - - await self.io.write(command) - if not wait_for_response: - return None - - t0 = time.time() - while True: - if time.time() - t0 > 120: # read for 2 minutes max. typical is 1m5s. - raise TimeoutError("Reading luminescence data timed out after 2 minutes.") - - response = await self.io.read(64, timeout=30) - if len(response) == 0: - continue - - # if the first 2 bytes do not match, we continue reading - response_report_id = Reader(response).u16() - if report_id == response_report_id: - break - return response - - def _background_ping_worker(self) -> None: - """Background worker that sends periodic ping commands.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - loop.run_until_complete(self._ping_loop()) - finally: - loop.close() - - async def _ping_loop(self) -> None: - """Main ping loop that runs in the background thread.""" - while not self._stop_background.is_set(): - if self._sending_pings: - # don't read in background thread, data might get lost here. don't use send_command - payload = Writer().u8(1).finish() - cmd = self._assemble_command( - report_id=0x0040, # command id: HEARTBEAT_IN - payload=payload, - routing_info=b"\x00\x00", - ) - await self.io.write(cmd) - - self._stop_background.wait(self._ping_interval) - - def _start_background_pings(self) -> None: - self._sending_pings = True - - def _stop_background_pings(self) -> None: - self._sending_pings = False - - async def open(self) -> None: - raise NotImplementedError( - "byonoy cannot open by itself. you need to move the top module using a robot arm." - ) - - async def close(self, plate: Optional[Plate]) -> None: - raise NotImplementedError( - "byonoy cannot close by itself. you need to move the top module using a robot arm." - ) - - -class ByonoyAbsorbance96AutomateBackend(_ByonoyBase): - def __init__(self) -> None: - super().__init__(pid=0x1199, device_type=_ByonoyDevice.ABSORBANCE_96) - - async def setup(self, verbose: bool = False, **backend_kwargs): - """Set up the plate reader. This should be called before any other methods.""" - - # Call the base setup (opens HID) - await super().setup(**backend_kwargs) - - # After device is online, run reference initialisation - await self.initialize_measurements() - - self.available_wavelengths = await self.get_available_absorbance_wavelengths() - - async def get_available_absorbance_wavelengths(self) -> List[float]: - response = await self.send_command( - report_id=0x0330, - payload=b"\x00" * 60, # 30 x i16 - wait_for_response=True, - routing_info=b"\x80\x40", - ) - assert response is not None, "Failed to get available wavelengths." - - # Skip the first 2 bytes (report_id), then read 30 signed 16-bit integers - 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): - """Perform an absorbance measurement or reference measurement. - This contains all shared logic between initialization and real measurements.""" - - # (1) SUPPORTED_REPORTS_IN (0x0010) - await self.send_command( - report_id=0x0010, - payload=b"\x00" * 60, # seq, seq_len, ids[29] - wait_for_response=False, - ) - - # (2) DEVICE_DATA_READ_IN (0x0200) - payload2 = ( - Writer() - .u16(7) # field_index - .u8(0) # flags - .raw_bytes(b"\x00" * 52) # data - .finish() - ) - await self.send_command( - report_id=0x0200, - payload=payload2, - wait_for_response=False, - ) - - # (3) ABS_TRIGGER_MEASUREMENT_OUT (0x0320) - payload3 = ( - Writer() - .i16(signal_wl) - .i16(reference_wl) - .u8(int(is_reference)) - .u8(0) # flags - .finish() - ) - await self.send_command( - report_id=0x0320, - payload=payload3, - wait_for_response=False, - routing_info=b"\x00\x40", - ) - - # (4) Collect chunks (report_id 0x0500) - rows: List[float] = [] - t0 = time.time() - - while True: - if time.time() - t0 > 120: - raise TimeoutError("Measurement timeout.") - - chunk = await self.io.read(64, timeout=30) - if len(chunk) == 0: - continue - - reader = Reader(chunk) - report_id = reader.u16() - - # Only handle the measurement packets - 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)] - _ = reader.u8() # flags - _ = reader.u8() # progress - - rows.extend(row) - - if seq == seq_len - 1: - break - - return rows - - async def initialize_measurements(self): - """Perform the reference ABS measurement required by the firmware.""" - - # Standard reference wavelength used by Byonoy app - # required startup protocol to initialize the photodiode reference - 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[dict]: - """ - Measure sample absorbance in each well at the specified wavelength. - - Args: - wavelength: Signal wavelength in nanometers. - plate: The plate being read. Included for API uniformity. - wells: Subset of wells to return. If omitted, all 96 wells are returned. - """ - - assert wavelength in self.available_wavelengths, ( - f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." - ) - - rows = await self._run_abs_measurement( - signal_wl=wavelength, - reference_wl=0, - is_reference=False, - ) - - matrix = reshape_2d(rows, (8, 12)) - - # dictionary output for filtered wells - return [ - { - "wavelength": wavelength, - "time": time.time(), - "temperature": None, - "data": matrix, - } - ] - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[dict]: - raise NotImplementedError("Absorbance plate reader does not support luminescence reading.") - - async def read_fluorescence( - self, - plate: Plate, - wells, - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[dict]: - raise NotImplementedError("Absorbance plate reader does not support fluorescence reading.") - - -class ByonoyLuminescence96AutomateBackend(_ByonoyBase): - def __init__(self) -> None: - super().__init__(pid=0x119B, device_type=_ByonoyDevice.LUMINESCENCE_96) - - async def read_absorbance(self, plate, wells, wavelength) -> List[Dict]: - raise NotImplementedError( - "Luminescence plate reader does not support absorbance reading. Use ByonoyAbsorbance96Automate instead." - ) - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 - ) -> List[Dict]: - """integration_time: in seconds, default 2 s""" - - # SUPPORTED_REPORTS_IN (0x0010) - await self.send_command( - report_id=0x0010, - payload=b"\x00" * 60, # seq, seq_len, ids[29] - wait_for_response=False, - ) - - # DEVICE_DATA_READ_IN (0x0200) - payload2 = ( - Writer() - .u16(7) # field_index - .u8(0) # flags - .raw_bytes(b"\x00" * 52) # data - .finish() - ) - await self.send_command( - report_id=0x0200, - payload=payload2, - wait_for_response=False, - ) - - # LUM_TRIGGER_MEASUREMENT_OUT (0x0340) - payload3 = ( - Writer() - .i32(int(integration_time * 1000 * 1000)) # integration_time_us - .raw_bytes(b"\xff" * 12) # channels_selected - .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() - all_rows: List[float] = [] - - while True: - if time.time() - t0 > 120: # read for 2 minutes max. typical is 1m5s. - raise TimeoutError("Reading luminescence data timed out after 2 minutes.") - - chunk = await self.io.read(64, timeout=30) - if len(chunk) == 0: - continue - - reader = Reader(chunk) - report_id = reader.u16() - - if report_id == 0x0600: # REP_LUM96_MEASUREMENT_IN - seq = reader.u8() - seq_len = reader.u8() - _ = reader.u32() # integration_time_us - _ = reader.u32() # duration_ms - row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress - - all_rows.extend(row) - - if seq == seq_len - 1: - break - - hybrid_result = all_rows[96 * 0 : 96 * 1] - _ = all_rows[96 * 1 : 96 * 2] # counting_result - _ = all_rows[96 * 2 : 96 * 3] # sampling_result - _ = all_rows[96 * 3 : 96 * 4] # micro_counting_result - _ = all_rows[96 * 4 : 96 * 5] # micro_integration_result - _ = all_rows[96 * 5 : 96 * 6] # repetition_count - _ = all_rows[96 * 6 : 96 * 7] # integration_times - _ = all_rows[96 * 7 : 96 * 8] # below_breakdown_measurement - - return [ - { - "time": time.time(), - "temperature": None, - "data": reshape_2d(hybrid_result, (8, 12)), - } - ] - - async def read_fluorescence( - self, - plate: Plate, - wells, - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[Dict]: - raise NotImplementedError("Fluorescence plate reader does not support fluorescence reading.") diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96.py b/pylabrobot/plate_reading/byonoy/byonoy_l96.py deleted file mode 100644 index c9b080c5b33..00000000000 --- a/pylabrobot/plate_reading/byonoy/byonoy_l96.py +++ /dev/null @@ -1,176 +0,0 @@ -from typing import Optional, Tuple - -from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyLuminescence96AutomateBackend -from pylabrobot.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 - - -class _ByonoyLuminescenceReaderPlateHolder(PlateHolder): - """Custom plate holder that checks if the reader sits on the parent base. - This check is used to prevent crashes (moving plate onto holder while reader 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. - - The base unit is a simple resource that holds a plate. The reader unit sits on top of it. - """ - - 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] = None, - 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("ByonoyBase 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( - "ByonoyBase does not support assigning child resources directly. " - "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, respectively." - ) - - -class ByonoyLuminescence96Automate(PlateReader): - """Byonoy L96/L96A luminescence plate reader unit. - - This is the reader unit that sits on top of the base unit. - """ - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - preferred_pickup_location: Optional[Coordinate] = None, - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - backend=ByonoyLuminescence96AutomateBackend(), - model="Byonoy L96 Reader Unit", - preferred_pickup_location=preferred_pickup_location, - ) - - -def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96Automate: - """Create a Byonoy L96 reader unit `PlateReader`. - - Note: L96 (non-automate) does not have a preferred pickup location. - """ - return ByonoyLuminescence96Automate( - name=name, - size_x=139.7, # caliper - size_y=97.5, # caliper - size_z=35, # force z probing - 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, # caliper - size_y=97.5, # caliper - size_z=9.4, # force z probing - plate_holder_child_location=Coordinate(x=6.25, y=6.1, z=2.64), # caliper - reader_unit_holder_child_location=Coordinate(x=0, y=0, z=7.2), # z = 42.2 - 35 - ) - - -def byonoy_l96( - name: str, assign: bool = True -) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96Automate]: - """Creates a ByonoyLuminescenceBaseUnit and a PlateReader instance for L96 (non-automate). - - Args: - name: Base name for the resources. - assign: If True, the reader unit is assigned to the base unit's reader_unit_holder. - - Returns: - A tuple of (base_unit, reader_unit). - """ - 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 diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py deleted file mode 100644 index 4def8f3a806..00000000000 --- a/pylabrobot/plate_reading/chatterbox.py +++ /dev/null @@ -1,123 +0,0 @@ -import time -from typing import Dict, List, Optional - -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.resources import Plate, Well - - -class PlateReaderChatterboxBackend(PlateReaderBackend): - """An abstract class for a plate reader. Plate readers are devices that can read luminescence, - absorbance, or fluorescence from a plate.""" - - def __init__(self): - self.dummy_luminescence: List[List[Optional[float]]] = [[0.0] * 12] * 8 - self.dummy_absorbance: List[List[Optional[float]]] = [[0.0] * 12] * 8 - self.dummy_fluorescence: List[List[Optional[float]]] = [[0.0] * 12] * 8 - - async def setup(self) -> None: - print("Setting up the plate reader.") - - async def stop(self) -> None: - print("Stopping the plate reader.") - - async def open(self) -> None: - print("Opening the plate reader.") - - async def close(self, plate: Optional[Plate]) -> None: - print(f"Closing the plate reader with plate, {plate}.") - - def _print_plate_reading_wells(self, result: List[List[Optional[float]]]) -> None: - print("Read the following wells:") - - cell_width = 7 - precision = 3 - - def fmt_cell(val: Optional[float]) -> str: - if val is None: - return "" # print empty for None - return f"{val:.{precision}f}" - - def row_label(r: int) -> str: - return "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[r] if r < 26 else "?" - - num_rows = len(result) - num_cols = max(len(row) for row in result) - - # Header - top = " " * (len(row_label(num_cols - 1)) + 1) + "|" - for c in range(num_cols): - top += f"{c + 1:>{cell_width}}|" - print(top) - - # Divider - print("-" * len(top)) - - # Rows - for r in range(num_rows): - line = f"{row_label(r)} ".rjust(len(row_label(num_cols - 1)) + 1) + "|" - for c in range(num_cols): - line += f"{fmt_cell(result[r][c]):>{cell_width}}|" - print(line) - - def _mask_result( - self, result: List[List[Optional[float]]], wells: List[Well], plate: Plate - ) -> List[List[Optional[float]]]: - masked: List[List[Optional[float]]] = [ - [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) - ] - for well in wells: - r, c = well.get_row(), well.get_column() - if r < plate.num_items_y and c < plate.num_items_x: - masked[r][c] = result[r][c] - return masked - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[Dict]: - print(f"Reading luminescence at focal height {focal_height}.") - result = self._mask_result(self.dummy_luminescence, wells, plate) - self._print_plate_reading_wells(result) - return [ - { - "time": time.time(), - "temperature": float("nan"), - "data": result, - "em_wavelength": 0, - } - ] - - async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: - print(f"Reading absorbance at wavelength {wavelength}.") - result = self._mask_result(self.dummy_absorbance, wells, plate) - self._print_plate_reading_wells(result) - return [ - { - "time": time.time(), - "temperature": float("nan"), - "data": result, - "wavelength": wavelength, - } - ] - - async def read_fluorescence( - self, - plate: Plate, - wells: List[Well], - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[Dict]: - print( - f"Reading fluorescence at excitation wavelength {excitation_wavelength}, emission wavelength {emission_wavelength}, and focal height {focal_height}." - ) - result = self._mask_result(self.dummy_fluorescence, wells, plate) - self._print_plate_reading_wells(result) - return [ - { - "time": time.time(), - "temperature": float("nan"), - "data": result, - "ex_wavelength": excitation_wavelength, - "em_wavelength": emission_wavelength, - } - ] diff --git a/pylabrobot/plate_reading/imager.py b/pylabrobot/plate_reading/imager.py deleted file mode 100644 index c55e0737d7f..00000000000 --- a/pylabrobot/plate_reading/imager.py +++ /dev/null @@ -1,367 +0,0 @@ -import logging -import math -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 ( - AutoExposure, - AutoFocus, - Exposure, - FocalPosition, - Gain, - Image, - ImagingMode, - ImagingResult, - NoPlateError, - Objective, -) -from pylabrobot.resources import Plate, Resource, Rotation, Well - -try: - import cv2 # type: ignore - - CV2_AVAILABLE = True -except ImportError as e: - cv2 = None # type: ignore - CV2_AVAILABLE = False - _CV2_IMPORT_ERROR = e - -try: - import numpy as np # type: ignore -except ImportError: - np = None # type: ignore[assignment] - - -logger = logging.getLogger(__name__) - - -async def _golden_ratio_search( - func: Callable[..., Coroutine[Any, Any, float]], a: float, b: float, tol: float, timeout: float -): - """Golden ratio search to maximize a unimodal function `func` over the interval [a, b].""" - # thanks chat - phi = (1 + np.sqrt(5)) / 2 # Golden ratio - - c = b - (b - a) / phi - d = a + (b - a) / phi - - cache: Dict[float, float] = {} - - async def cached_func(x: float) -> float: - x = round(x / tol) * tol # round x to units of tol - if x not in cache: - cache[x] = await func(x) - return cache[x] - - t0 = time.time() - iteration = 0 - while abs(b - a) > tol: - if (await cached_func(c)) > (await cached_func(d)): - b = d - else: - a = c - c = b - (b - a) / phi - d = a + (b - a) / phi - if time.time() - t0 > timeout: - raise TimeoutError("Timeout while searching for optimal focus position") - iteration += 1 - logger.debug("Golden ratio search (autofocus) iteration %d, a=%s, b=%s", iteration, a, b) - - return (b + a) / 2 - - -class Imager(Resource, Machine): - """Microscope""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: ImagerBackend, - rotation: Optional[Rotation] = None, - category: Optional[str] = None, - model: Optional[str] = None, - ): - Resource.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model, - ) - Machine.__init__(self, backend=backend) - self.backend: ImagerBackend = backend # fix type - - self.register_will_assign_resource_callback(self._will_assign_resource) - - def _will_assign_resource(self, resource: Resource): - if len(self.children) >= 1: - raise ValueError( - f"Imager {self} already has a plate assigned (attempting to assign {resource})" - ) - - def get_plate(self) -> Plate: - if len(self.children) == 0: - raise NoPlateError("There is no plate in the plate reader.") - return cast(Plate, self.children[0]) - - async def _capture_auto_exposure( - self, - well: Union[Well, Tuple[int, int]], - mode: ImagingMode, - objective: Objective, - auto_exposure: AutoExposure, - focal_height: float, - gain: float, - **backend_kwargs, - ) -> ImagingResult: - """ - Capture an image with auto exposure. - - This function will iteratively adjust the exposure time until a good exposure is found. - It uses the provided `evaluate_exposure` function to determine if the exposure is good, too high, or too low. - It uses a weighted binary search to find the optimal exposure time. The search is weighted by exposure time, - meaning that instead of splitting the range in half, we split the range at the point that equalizes the integral - of the exposure time on both sides (this works out to be equal to the root mean square of the endpoints). - """ - - if focal_height == "auto": - raise ValueError("Focal height must be specified for auto exposure") - if gain == "auto": - raise ValueError("Gain must be specified for auto exposure") - - def _rms_split(low: float, high: float) -> float: - """Split point that equalizes ∫t dt on both sides (RMS of endpoints).""" - if low == high: - return low - return math.sqrt((low**2 + high**2) / 2) - - low, high = auto_exposure.low, auto_exposure.high - - rounds = 0 - while high - low > 1e-3: - if auto_exposure.max_rounds is not None and rounds >= auto_exposure.max_rounds: - raise ValueError("Exceeded maximum number of rounds") - rounds += 1 - - p = _rms_split(low, high) - res = await self.capture( - well=well, - mode=mode, - objective=objective, - exposure_time=p, - focal_height=focal_height, - gain=gain, - **backend_kwargs, - ) - assert len(res.images) == 1, "Expected exactly one image to be returned" - im = res.images[0] - evaluation = await auto_exposure.evaluate_exposure(im) - - if evaluation == "good": - return res - if evaluation == "lower": - high = p - elif evaluation == "higher": - low = p - else: - raise ValueError(f"Unexpected evaluation result: {evaluation}") - - raise RuntimeError("Failed to find a good exposure time.") - - async def _capture_auto_focus( - self, - well: Union[Well, Tuple[int, int]], - mode: ImagingMode, - objective: Objective, - exposure_time: float, - auto_focus: AutoFocus, - gain: float, - **backend_kwargs, - ) -> ImagingResult: - async def local_capture(focal_height: float) -> ImagingResult: - return await self.capture( - well=well, - mode=mode, - objective=objective, - exposure_time=exposure_time, - focal_height=focal_height, - gain=gain, - **backend_kwargs, - ) - - async def capture_and_evaluate(focal_height: float) -> float: - res = await local_capture(focal_height) - return auto_focus.evaluate_focus(res.images[0]) - - # Use golden ratio search to find the best focus value - best_focal_height = await _golden_ratio_search( - func=capture_and_evaluate, - a=auto_focus.low, - b=auto_focus.high, - tol=auto_focus.tolerance, # 1 micron - timeout=auto_focus.timeout, - ) - return await local_capture(best_focal_height) - - @need_setup_finished - async def capture( - self, - well: Union[Well, Tuple[int, int]], - mode: ImagingMode, - objective: Objective, - exposure_time: Union[Exposure, AutoExposure] = "machine-auto", - focal_height: FocalPosition = "machine-auto", - gain: Gain = "machine-auto", - **backend_kwargs, - ) -> ImagingResult: - if exposure_time != "machine-auto" and not isinstance( - exposure_time, (int, float, AutoExposure) - ): - raise TypeError(f"Invalid exposure time: {exposure_time}") - if ( - not isinstance(focal_height, (int, float)) - and focal_height != "machine-auto" - and not isinstance(focal_height, AutoFocus) - ): - raise TypeError(f"Invalid focal height: {focal_height}") - - if isinstance(well, tuple): - row, column = well - else: - idx = cast(Plate, well.parent).index_of_item(well) - if idx is None: - raise ValueError(f"Well {well} not in plate {well.parent}") - row, column = divmod(idx, cast(Plate, well.parent).num_items_x) - - if isinstance(exposure_time, AutoExposure): - assert focal_height != "machine-auto", "Focal height must be specified for auto exposure" - assert gain != "machine-auto", "Gain must be specified for auto exposure" - return await self._capture_auto_exposure( - well=well, - mode=mode, - objective=objective, - auto_exposure=exposure_time, - focal_height=focal_height, - gain=gain, - **backend_kwargs, - ) - - if isinstance(focal_height, AutoFocus): - assert isinstance(exposure_time, (int, float)), ( - "Exposure time must be specified for auto focus" - ) - assert gain != "machine-auto", "Gain must be specified for auto focus" - return await self._capture_auto_focus( - well=well, - mode=mode, - objective=objective, - exposure_time=exposure_time, - auto_focus=focal_height, - gain=gain, - **backend_kwargs, - ) - - return await self.backend.capture( - row=row, - column=column, - mode=mode, - objective=objective, - exposure_time=exposure_time, - focal_height=focal_height, - gain=gain, - plate=self.get_plate(), - **backend_kwargs, - ) - - -def max_pixel_at_fraction( - fraction: float, margin: float -) -> Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]: - """The maximum pixel value in a given image should be a fraction of the maximum possible pixel value (eg 255 for 8-bit images). - - Args: - fraction: the desired fraction of the actual maximum pixel value over the theoretically maximum pixel value (e.g. 0.8 for 80%). If it is an 8-bit image, the maximum value would be 0.8 * 255 = 204. - margin: the margin of error that is accepted. A fraction of the theoretical maximum pixel value, e.g. 0.05 for 5%, so the maximum pixel value should be between 0.75 * 255 and 0.85 * 255. - """ - - if np is None: - raise ImportError("numpy is required for max_pixel_at_fraction") - - async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]: - array = np.array(im, dtype=np.float32) - value = np.max(array) - (255.0 * fraction) - margin_value = 255.0 * margin - if abs(value) <= margin_value: - return "good" - # lower the exposure time if the max pixel value is too high - return "lower" if value > 0 else "higher" - - return evaluate_exposure - - -def fraction_overexposed( - fraction: float, margin: float, max_pixel_value: int = 255 -) -> Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]: - """A certain fraction of pixels in the image should be overexposed (e.g. 0.5%). - - 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. - - Args: - fraction: the desired fraction of pixels that should be overexposed (e.g. 0.005 for 0.5%). Overexposed is defined as pixels with a value greater than the maximum pixel value (e.g. 255 for 8-bit images). You can customize this number if needed. - margin: the margin of error for the fraction of pixels that should be overexposed (e.g. 0.001 for 0.1%, so the fraction of overexposed pixels should be between 0.004 and 0.006). - max_pixel_value: the maximum pixel value for the image (e.g. 255 for 8-bit images). You can override it to change the definition of "overexposed" pixels. - """ - - if np is None: - raise ImportError("numpy is required for fraction_overexposed") - - async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]: - # count the number of pixels that are overexposed - arr = np.asarray(im, dtype=np.uint8) - actual_fraction = np.count_nonzero(arr > max_pixel_value) / arr.size - lower_bound, upper_bound = fraction - margin, fraction + margin - if lower_bound <= actual_fraction <= upper_bound: - return "good" - # too many saturated pixels -> shorten exposure - return "lower" if (actual_fraction - fraction) > 0 else "higher" - - return evaluate_exposure - - -def evaluate_focus_nvmg_sobel(image: Image) -> float: - """Evaluate the focus of an image using the Normalized Variance of the Gradient Magnitude (NVMG) method with Sobel filters. - - I think Chat invented this method. - - Only uses the center 50% of the image to avoid edge effects. - """ - if not CV2_AVAILABLE: - raise RuntimeError( - f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}" - ) - - # cut out 25% on each side - np_image = np.array(image, dtype=np.float64) - height, width = np_image.shape[:2] - crop_height = height // 4 - crop_width = width // 4 - np_image = np_image[crop_height : height - crop_height, crop_width : width - crop_width] - - # NVMG: Normalized Variance of the Gradient Magnitude - # Chat invented this i think - sobel_x = cv2.Sobel(np_image, cv2.CV_64F, 1, 0, ksize=3) - sobel_y = cv2.Sobel(np_image, cv2.CV_64F, 0, 1, ksize=3) - gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2) - - mean_gm = np.mean(gradient_magnitude) - var_gm = np.var(gradient_magnitude) - sharpness = var_gm / (mean_gm + 1e-6) - return cast(float, sharpness) 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/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py deleted file mode 100644 index a44e6783261..00000000000 --- a/pylabrobot/plate_reading/plate_reader.py +++ /dev/null @@ -1,204 +0,0 @@ -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.resources import Coordinate, Plate, Resource, ResourceHolder, Rotation, Well - -logger = logging.getLogger(__name__) - - -class PlateReader(ResourceHolder, Machine): - """The front end for plate readers. Plate readers are devices that can read luminescence, - absorbance, or fluorescence from a plate. - - Plate readers are asynchronous, meaning that their methods will return immediately and - will not block. - - Here's an example of how to use this class in a Jupyter Notebook: - - >>> from pylabrobot.plate_reading.clario_star import CLARIOStarBackend - >>> pr = PlateReader(backend=CLARIOStarBackend()) - >>> pr.setup() - >>> await pr.read_luminescence() - [[value1, value2, value3, ...], [value1, value2, value3, ...], ... - """ - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: PlateReaderBackend, - rotation: Optional["Rotation"] = None, - category: Optional[str] = "plate_reader", - model: Optional[str] = None, - child_location: Coordinate = Coordinate.zero(), - preferred_pickup_location: Optional[Coordinate] = None, - ) -> None: - ResourceHolder.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model, - child_location=child_location, - preferred_pickup_location=preferred_pickup_location, - ) - Machine.__init__(self, backend=backend) - self.backend: PlateReaderBackend = backend # fix type - - def assign_child_resource( - self, - resource: Resource, - location: Optional[Coordinate] = None, - reassign: bool = True, - ): - if len([c for c in self.children if isinstance(c, Plate)]) >= 1: - raise ValueError("There already is a plate in the plate reader.") - - super().assign_child_resource(resource, location=location, reassign=reassign) - - def get_plate(self) -> Plate: - plate_children = [c for c in self.children if isinstance(c, Plate)] - if len(plate_children) == 0: - raise NoPlateError("There is no plate in the plate reader.") - return cast(Plate, plate_children[0]) - - @need_setup_finished - async def open(self, **backend_kwargs) -> None: - await self.backend.open(**backend_kwargs) - - @need_setup_finished - async def close(self, **backend_kwargs) -> None: - plate = self.get_plate() if len(self.children) > 0 else None - await self.backend.close(plate=plate, **backend_kwargs) - - @need_setup_finished - async def read_luminescence( - self, - focal_height: float, - wells: Optional[List[Well]] = None, - use_new_return_type: bool = False, - **backend_kwargs, - ) -> List[Dict]: - """Read the luminescence from the plate reader. - - Args: - focal_height: The focal height to read the luminescence at, in millimeters. - use_new_return_type: Whether to return the new return type, which is a list of dictionaries. - - Returns: - A list of dictionaries, one for each measurement. Each dictionary contains: - "time": float, - "temperature": float, - "data": List[List[float]] - """ - - result = await self.backend.read_luminescence( - plate=self.get_plate(), - wells=wells or self.get_plate().get_all_items(), - focal_height=focal_height, - **backend_kwargs, - ) - - if not use_new_return_type: - logger.warning( - "The return type of read_luminescence will change in a future version. Please set " - "use_new_return_type=True to use the new return type." - ) - return result[0]["data"] # type: ignore[no-any-return] - return result - - @need_setup_finished - async def read_absorbance( - self, - wavelength: int, - wells: Optional[List[Well]] = None, - use_new_return_type: bool = False, - **backend_kwargs, - ) -> List[Dict]: - """Read the absorbance from the plate reader. - - Args: - wavelength: The wavelength to read the absorbance at, in nanometers. - use_new_return_type: Whether to return the new return type, which is a list of dictionaries. - - Returns: - A list of dictionaries, one for each measurement. Each dictionary contains: - "wavelength": int, - "time": float, - "temperature": float, - "data": List[List[float]] - """ - - result = await self.backend.read_absorbance( - plate=self.get_plate(), - wells=wells or self.get_plate().get_all_items(), - wavelength=wavelength, - **backend_kwargs, - ) - - if not use_new_return_type: - logger.warning( - "The return type of read_absorbance will change in a future version. Please set " - "use_new_return_type=True to use the new return type." - ) - return result[0]["data"] # type: ignore[no-any-return] - return result - - @need_setup_finished - async def read_fluorescence( - self, - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - wells: Optional[List[Well]] = None, - use_new_return_type: bool = False, - **backend_kwargs, - ) -> List[Dict]: - """Read the fluorescence from the plate reader. - - Args: - excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers. - emission_wavelength: The emission wavelength to read the fluorescence at, in nanometers. - focal_height: The focal height to read the fluorescence at, in millimeters. - use_new_return_type: Whether to return the new return type, which is a list of dictionaries. - - Returns: - A list of dictionaries, one for each measurement. Each dictionary contains: - "ex_wavelength": int, - "em_wavelength": int, - "time": float, - "temperature": float, - "data": List[List[float]] - """ - - if excitation_wavelength > emission_wavelength: - logger.warning( - "Excitation wavelength is greater than emission wavelength. This is unusual and may indicate an error." - ) - - result = await self.backend.read_fluorescence( - plate=self.get_plate(), - wells=wells or self.get_plate().get_all_items(), - excitation_wavelength=excitation_wavelength, - emission_wavelength=emission_wavelength, - focal_height=focal_height, - **backend_kwargs, - ) - if not use_new_return_type: - logger.warning( - "The return type of read_fluorescence will change in a future version. Please set " - "use_new_return_type=True to use the new return type." - ) - return result[0]["data"] # type: ignore[no-any-return] - return result - - def serialize(self) -> dict: - return {**Resource.serialize(self), **Machine.serialize(self)} 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_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py deleted file mode 100644 index 992e422c342..00000000000 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ /dev/null @@ -1,1344 +0,0 @@ -"""Tecan Infinite 200 PRO backend. - -This backend targets the Infinite "M" series (e.g., Infinite 200 PRO). The -"F" series uses a different optical path and is not covered here. -""" - -from __future__ import annotations - -import asyncio -import logging -import math -import re -import time -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, Tuple - -from pylabrobot.io.binary import Reader -from pylabrobot.io.usb import USB -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.resources import Plate -from pylabrobot.resources.well import Well - -logger = logging.getLogger(__name__) -BIN_RE = re.compile(r"^(\d+),BIN:$") - - -def _integration_microseconds_to_seconds(value: int) -> float: - # DLL/UI indicates integration time is stored in microseconds; UI displays ms by dividing by 1000. - return value / 1_000_000.0 - - -def _is_abs_calibration_len(payload_len: int) -> bool: - return payload_len >= 22 and (payload_len - 4) % 18 == 0 - - -def _is_abs_data_len(payload_len: int) -> bool: - return payload_len >= 14 and (payload_len - 4) % 10 == 0 - - -def _split_payload_and_trailer( - payload_len: int, blob: bytes -) -> Optional[Tuple[bytes, Tuple[int, int]]]: - if len(blob) != payload_len + 4: - return None - payload = blob[:payload_len] - trailer_reader = Reader(blob[payload_len:], little_endian=False) - return payload, (trailer_reader.u16(), trailer_reader.u16()) - - -@dataclass(frozen=True) -class _AbsorbanceCalibrationItem: - ticker_overflows: int - ticker_counter: int - meas_gain: int - meas_dark: int - meas_bright: int - ref_gain: int - ref_dark: int - ref_bright: int - - -@dataclass(frozen=True) -class _AbsorbanceCalibration: - ex: int - items: List[_AbsorbanceCalibrationItem] - - -def _decode_abs_calibration(payload_len: int, blob: bytes) -> Optional[_AbsorbanceCalibration]: - split = _split_payload_and_trailer(payload_len, blob) - if split is None: - return None - payload, _ = split - if len(payload) < 4 + 18: - return None - if (len(payload) - 4) % 18 != 0: - return None - reader = Reader(payload, little_endian=False) - reader.raw_bytes(2) # skip first 2 bytes - ex = reader.u16() - items: List[_AbsorbanceCalibrationItem] = [] - while reader.has_remaining(): - items.append( - _AbsorbanceCalibrationItem( - ticker_overflows=reader.u32(), - ticker_counter=reader.u16(), - meas_gain=reader.u16(), - meas_dark=reader.u16(), - meas_bright=reader.u16(), - ref_gain=reader.u16(), - ref_dark=reader.u16(), - ref_bright=reader.u16(), - ) - ) - return _AbsorbanceCalibration(ex=ex, items=items) - - -def _decode_abs_data( - payload_len: int, blob: bytes -) -> Optional[Tuple[int, int, List[Tuple[int, int]]]]: - split = _split_payload_and_trailer(payload_len, blob) - if split is None: - return None - payload, _ = split - if len(payload) < 4: - return None - reader = Reader(payload, little_endian=False) - label = reader.u16() - ex = reader.u16() - items: List[Tuple[int, int]] = [] - while reader.offset() + 10 <= len(payload): - reader.raw_bytes(6) # skip first 6 bytes of each item - meas = reader.u16() - ref = reader.u16() - items.append((meas, ref)) - if reader.offset() != len(payload): - return None - return label, ex, items - - -def _absorbance_od_calibrated( - cal: _AbsorbanceCalibration, meas_ref_items: List[Tuple[int, int]], od_max: float = 4.0 -) -> float: - if not cal.items: - raise ValueError("ABS calibration packet contained no calibration items.") - - min_corr_trans = math.pow(10.0, -od_max) - - if len(cal.items) == len(meas_ref_items) and len(cal.items) > 1: - corr_trans_vals: List[float] = [] - for (meas, ref), cal_item in zip(meas_ref_items, cal.items): - denom_corr = cal_item.meas_bright - cal_item.meas_dark - if denom_corr == 0: - continue - f_corr = (cal_item.ref_bright - cal_item.ref_dark) / denom_corr - denom = ref - cal_item.ref_dark - if denom == 0: - continue - corr_trans_vals.append(((meas - cal_item.meas_dark) / denom) * f_corr) - if not corr_trans_vals: - raise ZeroDivisionError("ABS invalid: no usable reads after per-read calibration.") - corr_trans = max(sum(corr_trans_vals) / len(corr_trans_vals), min_corr_trans) - return float(-math.log10(corr_trans)) - - cal0 = cal.items[0] - denom_corr = cal0.meas_bright - cal0.meas_dark - if denom_corr == 0: - raise ZeroDivisionError("ABS calibration invalid: meas_bright == meas_dark") - f_corr = (cal0.ref_bright - cal0.ref_dark) / denom_corr - - trans_vals: List[float] = [] - for meas, ref in meas_ref_items: - denom = ref - cal0.ref_dark - if denom == 0: - continue - trans_vals.append((meas - cal0.meas_dark) / denom) - if not trans_vals: - raise ZeroDivisionError("ABS invalid: all ref reads equal ref_dark") - - trans_mean = sum(trans_vals) / len(trans_vals) - corr_trans = max(trans_mean * f_corr, min_corr_trans) - return float(-math.log10(corr_trans)) - - -@dataclass(frozen=True) -class _FluorescenceCalibration: - ex: int - meas_dark: int - ref_dark: int - ref_bright: int - - -def _decode_flr_calibration(payload_len: int, blob: bytes) -> Optional[_FluorescenceCalibration]: - split = _split_payload_and_trailer(payload_len, blob) - if split is None: - return None - payload, _ = split - if len(payload) != 18: - return None - reader = Reader(payload, little_endian=False) - ex = reader.u16() - reader.raw_bytes(8) # skip bytes 2-9 - meas_dark = reader.u16() - reader.raw_bytes(2) # skip bytes 12-13 - ref_dark = reader.u16() - ref_bright = reader.u16() - return _FluorescenceCalibration( - ex=ex, - meas_dark=meas_dark, - ref_dark=ref_dark, - ref_bright=ref_bright, - ) - - -def _decode_flr_data( - payload_len: int, blob: bytes -) -> Optional[Tuple[int, int, int, List[Tuple[int, int]]]]: - split = _split_payload_and_trailer(payload_len, blob) - if split is None: - return None - payload, _ = split - if len(payload) < 6: - return None - reader = Reader(payload, little_endian=False) - label = reader.u16() - ex = reader.u16() - em = reader.u16() - items: List[Tuple[int, int]] = [] - while reader.offset() + 10 <= len(payload): - reader.raw_bytes(6) # skip first 6 bytes of each item - meas = reader.u16() - ref = reader.u16() - items.append((meas, ref)) - if reader.offset() != len(payload): - return None - return label, ex, em, items - - -def _fluorescence_corrected( - cal: _FluorescenceCalibration, meas_ref_items: List[Tuple[int, int]] -) -> int: - if not meas_ref_items: - return 0 - meas_mean = sum(m for m, _ in meas_ref_items) / len(meas_ref_items) - ref_mean = sum(r for _, r in meas_ref_items) / len(meas_ref_items) - denom = ref_mean - cal.ref_dark - if denom == 0: - return 0 - corr = (meas_mean - cal.meas_dark) * (cal.ref_bright - cal.ref_dark) / denom - return int(round(corr)) - - -@dataclass(frozen=True) -class _LuminescenceCalibration: - ref_dark: int - - -def _decode_lum_calibration(payload_len: int, blob: bytes) -> Optional[_LuminescenceCalibration]: - split = _split_payload_and_trailer(payload_len, blob) - if split is None: - return None - payload, _ = split - if len(payload) != 10: - return None - reader = Reader(payload, little_endian=False) - reader.raw_bytes(6) # skip bytes 0-5 - return _LuminescenceCalibration(ref_dark=reader.i32()) - - -def _decode_lum_data(payload_len: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: - split = _split_payload_and_trailer(payload_len, blob) - if split is None: - return None - payload, _ = split - if len(payload) < 4: - return None - reader = Reader(payload, little_endian=False) - label = reader.u16() - em = reader.u16() - counts: List[int] = [] - while reader.offset() + 10 <= len(payload): - reader.raw_bytes(6) # skip first 6 bytes of each item - counts.append(reader.i32()) - if reader.offset() != len(payload): - return None - return label, em, counts - - -def _luminescence_intensity( - cal: _LuminescenceCalibration, - counts: List[int], - dark_integration_s: float, - meas_integration_s: float, -) -> int: - if not counts: - return 0 - if dark_integration_s == 0 or meas_integration_s == 0: - return 0 - count_mean = sum(counts) / len(counts) - corrected_rate = (count_mean / meas_integration_s) - (cal.ref_dark / dark_integration_s) - return int(corrected_rate) - - -StagePosition = Tuple[int, int] - - -def _consume_leading_ascii_frame(buffer: bytearray) -> Tuple[bool, Optional[str]]: - """Remove a leading STX...ETX ASCII frame if present.""" - - if not buffer or buffer[0] != 0x02: - return False, None - end = buffer.find(b"\x03", 1) - if end == -1: - return False, None - # Payload is followed by a 4-byte trailer and optional CR. - if len(buffer) < end + 5: - return False, None - text = buffer[1:end].decode("ascii", "ignore") - del buffer[: end + 5] - if buffer and buffer[0] == 0x0D: - del buffer[0] - return True, text - - -def _consume_status_frame(buffer: bytearray, length: int) -> bool: - """Drop a leading ESC-prefixed status frame if present.""" - - if len(buffer) >= length and buffer[0] == 0x1B: - del buffer[:length] - return True - return False - - -@dataclass -class _StreamEvent: - """Parsed stream event (ASCII or binary).""" - - text: Optional[str] = None - payload_len: Optional[int] = None - blob: Optional[bytes] = None - - -class _StreamParser: - """Parse mixed ASCII and binary packets from the reader.""" - - def __init__( - self, - *, - status_frame_len: Optional[int] = None, - allow_bare_ascii: bool = False, - ) -> None: - """Initialize the stream parser.""" - self._buffer = bytearray() - self._pending_bin: Optional[int] = None - self._status_frame_len = status_frame_len - self._allow_bare_ascii = allow_bare_ascii - - def has_pending_bin(self) -> bool: - """Return True if a binary payload length is pending.""" - return self._pending_bin is not None - - def feed(self, chunk: bytes) -> List[_StreamEvent]: - """Feed raw bytes and return newly parsed events.""" - self._buffer.extend(chunk) - events: List[_StreamEvent] = [] - progressed = True - while progressed: - progressed = False - if self._pending_bin is not None: - need = self._pending_bin + 4 - if len(self._buffer) < need: - break - blob = bytes(self._buffer[:need]) - del self._buffer[:need] - events.append(_StreamEvent(payload_len=self._pending_bin, blob=blob)) - self._pending_bin = None - progressed = True - continue - if self._status_frame_len and _consume_status_frame(self._buffer, self._status_frame_len): - progressed = True - continue - consumed, text = _consume_leading_ascii_frame(self._buffer) - if consumed: - events.append(_StreamEvent(text=text)) - if text: - m = BIN_RE.match(text) - if m: - self._pending_bin = int(m.group(1)) - progressed = True - continue - if self._allow_bare_ascii and self._buffer and all(32 <= b <= 126 for b in self._buffer): - text = self._buffer.decode("ascii", "ignore") - self._buffer.clear() - events.append(_StreamEvent(text=text)) - progressed = True - continue - return events - - -class _MeasurementDecoder(ABC): - """Shared incremental decoder for Infinite measurement streams.""" - - STATUS_FRAME_LEN: Optional[int] = None - - def __init__(self, expected: int) -> None: - """Initialize decoder state for a scan with expected measurements.""" - self.expected = expected - self._terminal_seen = False - self._parser = _StreamParser(status_frame_len=self.STATUS_FRAME_LEN) - - @property - @abstractmethod - def count(self) -> int: - """Return number of decoded measurements so far.""" - - @property - def done(self) -> bool: - """Return True if the decoder has seen all expected measurements.""" - return self.count >= self.expected - - def pop_terminal(self) -> bool: - """Return and clear the terminal frame seen flag.""" - seen = self._terminal_seen - self._terminal_seen = False - return seen - - def feed(self, chunk: bytes) -> None: - """Consume a raw chunk and update decoder state.""" - for event in self._parser.feed(chunk): - if event.text is not None: - if event.text == "ST": - self._terminal_seen = True - elif event.payload_len is not None and event.blob is not None: - self.feed_bin(event.payload_len, event.blob) - - def feed_bin(self, payload_len: int, blob: bytes) -> None: - """Handle a binary payload if the decoder expects one.""" - if self._should_consume_bin(payload_len): - self._handle_bin(payload_len, blob) - - def _should_consume_bin(self, _payload_len: int) -> bool: - return False - - def _handle_bin(self, _payload_len: int, _blob: bytes) -> None: - return None - - -class ExperimentalTecanInfinite200ProBackend(PlateReaderBackend): - """Backend shell for the Infinite 200 PRO.""" - - _MODE_CAPABILITY_COMMANDS: Dict[str, List[str]] = { - "ABS": [ - "#BEAM DIAMETER", - # Additional capabilities available but currently unused: - # "#EXCITATION WAVELENGTH", - # "#EXCITATION USAGE", - # "#EXCITATION NAME", - # "#EXCITATION BANDWIDTH", - # "#EXCITATION ATTENUATION", - # "#EXCITATION DESCRIPTION", - # "#TIME READDELAY", - # "#SHAKING MODE", - # "#SHAKING CONST.ORBITAL", - # "#SHAKING AMPLITUDE", - # "#SHAKING TIME", - # "#SHAKING CONST.LINEAR", - # "#TEMPERATURE PLATE", - ], - "FI.TOP": [ - # "#BEAM DIAMETER", - # Additional capabilities available but currently unused: - # "#EMISSION WAVELENGTH", - # "#EMISSION USAGE", - # "#EMISSION NAME", - # "#EMISSION BANDWIDTH", - # "#EMISSION ATTENUATION", - # "#EMISSION DESCRIPTION", - # "#EXCITATION WAVELENGTH", - # "#EXCITATION USAGE", - # "#EXCITATION NAME", - # "#EXCITATION BANDWIDTH", - # "#EXCITATION ATTENUATION", - # "#EXCITATION DESCRIPTION", - # "#TIME INTEGRATION", - # "#TIME LAG", - # "#TIME READDELAY", - # "#GAIN VALUE", - # "#READS SPEED", - # "#READS NUMBER", - # "#RANGES PMT,EXCITATION", - # "#RANGES PMT,EMISSION", - # "#POSITION FIL,Z", - # "#TEMPERATURE PLATE", - ], - "FI.BOTTOM": [ - # "#BEAM DIAMETER", - # Additional capabilities available but currently unused: - # "#EMISSION WAVELENGTH", - # "#EMISSION USAGE", - # "#EXCITATION WAVELENGTH", - # "#EXCITATION USAGE", - # "#TIME INTEGRATION", - # "#TIME LAG", - # "#TIME READDELAY", - ], - "LUM": [ - # "#BEAM DIAMETER", - # Additional capabilities available but currently unused: - # "#EMISSION WAVELENGTH", - # "#EMISSION USAGE", - # "#EMISSION NAME", - # "#EMISSION BANDWIDTH", - # "#EMISSION ATTENUATION", - # "#EMISSION DESCRIPTION", - # "#TIME INTEGRATION", - # "#TIME READDELAY", - ], - } - - VENDOR_ID = 0x0C47 - PRODUCT_ID = 0x8007 - - def __init__( - self, - counts_per_mm_x: float = 1_000, - counts_per_mm_y: float = 1_000, - counts_per_mm_z: float = 1_000, - ) -> None: - super().__init__() - self.io = USB( - id_vendor=self.VENDOR_ID, - id_product=self.PRODUCT_ID, - human_readable_device_name="Tecan Infinite 200 PRO", - packet_read_timeout=3, - read_timeout=30, - ) - self.counts_per_mm_x = counts_per_mm_x - self.counts_per_mm_y = counts_per_mm_y - self.counts_per_mm_z = counts_per_mm_z - self._setup_lock = asyncio.Lock() - self._ready = False - self._read_chunk_size = 512 - self._max_row_wait_s = 300.0 - self._mode_capabilities: Dict[str, Dict[str, str]] = {} - self._pending_bin_events: List[Tuple[int, bytes]] = [] - self._parser = _StreamParser(allow_bare_ascii=True) - self._run_active = False - self._active_step_loss_commands: List[str] = [] - - async def setup(self) -> None: - async with self._setup_lock: - if self._ready: - return - await self.io.setup() - await self._initialize_device() - for mode in self._MODE_CAPABILITY_COMMANDS: - if mode not in self._mode_capabilities: - await self._query_mode_capabilities(mode) - self._ready = True - - async def stop(self) -> None: - async with self._setup_lock: - if not self._ready: - return - await self._cleanup_protocol() - await self.io.stop() - self._mode_capabilities.clear() - self._reset_stream_state() - self._ready = False - - async def open(self) -> None: - """Open the reader drawer.""" - - await self._send_command("ABSOLUTE MTP,OUT") - await self._send_command("BY#T5000") - - async def close(self, plate: Optional[Plate]) -> None: # noqa: ARG002 - """Close the reader drawer.""" - - await self._send_command("ABSOLUTE MTP,IN") - await self._send_command("BY#T5000") - - async def _run_scan( - self, - ordered_wells: Sequence[Well], - decoder: _MeasurementDecoder, - mode: str, - step_loss_commands: List[str], - serpentine: bool, - scan_direction: str, - ) -> None: - """Run the common scan loop for all measurement types. - - Args: - ordered_wells: The wells to scan in row-major order. - decoder: The decoder to use for parsing measurements. - mode: The mode name for logging (e.g., "Absorbance"). - step_loss_commands: Commands to run after the scan to check for step loss. - serpentine: Whether to use serpentine scan order. - scan_direction: The scan direction command (e.g., "ALTUP", "UP"). - """ - self._active_step_loss_commands = step_loss_commands - - for row_index, row_wells in self._group_by_row(ordered_wells): - start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=serpentine) - _, y_stage = self._map_well_to_stage(row_wells[0]) - - await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") - # Match the OEM one-row scan flow by explicitly pre-positioning the transport to the - # row start before issuing SCANX. Hardware testing showed the standalone XY move alone - # can reintroduce the first-row edge-read problem. - await self._send_command(f"ABSOLUTE MTP,X={start_x},Y={y_stage}") - await self._send_command(f"SCAN DIRECTION={scan_direction}") - await self._send_command( - f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False - ) - logger.info( - "Queued %s scan row %s (%s wells): y=%s, x=%s..%s", - mode.lower(), - row_index, - count, - y_stage, - start_x, - end_x, - ) - await self._await_measurements(decoder, count, mode) - await self._await_scan_terminal(decoder.pop_terminal()) - - async def read_absorbance( - self, - plate: Plate, - wells: List[Well], - wavelength: int, - flashes: int = 25, - bandwidth: Optional[float] = None, - ) -> List[Dict]: - """Queue and execute an absorbance scan. - - Args: - bandwidth: Excitation bandwidth in nm. If None, auto-selected (9 nm for >315 nm, 5 nm - otherwise). - """ - - if not 230 <= wavelength <= 1_000: - raise ValueError("Absorbance wavelength must be between 230 nm and 1000 nm.") - - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) - decoder = _AbsorbanceRunDecoder(len(scan_wells)) - - await self._begin_run() - try: - await self._configure_absorbance(wavelength, flashes=flashes, bandwidth=bandwidth) - await self._run_scan( - ordered_wells=ordered_wells, - decoder=decoder, - mode="Absorbance", - step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"], - serpentine=True, - scan_direction="ALTUP", - ) - - self._drain_pending_bin_events(decoder) - if len(decoder.measurements) != len(scan_wells): - raise RuntimeError("Absorbance decoder did not complete scan.") - intensities: List[float] = [] - cal = decoder.calibration - if cal is None: - raise RuntimeError("ABS calibration packet not seen; cannot compute calibrated OD.") - for meas in decoder.measurements: - items = meas.items or [(meas.sample, meas.reference)] - od = _absorbance_od_calibrated(cal, items) - intensities.append(od) - matrix = self._format_plate_result(plate, scan_wells, intensities) - return [ - { - "wavelength": wavelength, - "time": time.time(), - "temperature": None, - "data": matrix, - } - ] - finally: - await self._end_run() - - async def _clear_mode_settings(self, excitation: bool = False, emission: bool = False) -> None: - """Clear mode settings before configuring a new scan.""" - if excitation: - await self._send_command("EXCITATION CLEAR", allow_timeout=True) - if emission: - await self._send_command("EMISSION CLEAR", allow_timeout=True) - await self._send_command("TIME CLEAR", allow_timeout=True) - await self._send_command("GAIN CLEAR", allow_timeout=True) - await self._send_command("READS CLEAR", allow_timeout=True) - await self._send_command("POSITION CLEAR", allow_timeout=True) - await self._send_command("MIRROR CLEAR", allow_timeout=True) - - async def _configure_absorbance( - self, - wavelength_nm: int, - *, - flashes: int, - bandwidth: Optional[float] = None, - ) -> None: - wl_decitenth = int(round(wavelength_nm * 10)) - bw = bandwidth if bandwidth is not None else self._auto_bandwidth(wavelength_nm) - bw_decitenth = int(round(bw * 10)) - reads_number = max(1, flashes) - - await self._send_command("MODE ABS") - await self._clear_mode_settings(excitation=True) - await self._send_command( - f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True - ) - await self._send_command( - f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True - ) - await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) - await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) - await self._send_command("TIME 0,READDELAY=0", allow_timeout=True) - await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) - await self._send_command("SCAN DIRECTION=ALTUP", allow_timeout=True) - await self._send_command("#RATIO LABELS", allow_timeout=True) - await self._send_command( - f"BEAM DIAMETER={self._capability_numeric('ABS', '#BEAM DIAMETER', 700)}", allow_timeout=True - ) - await self._send_command("RATIO LABELS=1", allow_timeout=True) - await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) - - def _auto_bandwidth(self, wavelength_nm: int) -> float: - """Return bandwidth in nm based on Infinite M specification.""" - - return 9.0 if wavelength_nm > 315 else 5.0 - - async def read_fluorescence( - self, - plate: Plate, - wells: List[Well], - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float = 20.0, - flashes: int = 25, - integration_us: int = 20, - gain: int = 100, - excitation_bandwidth: int = 50, - emission_bandwidth: int = 200, - lag_us: int = 0, - ) -> List[Dict]: - """Queue and execute a fluorescence scan. - - Args: - gain: PMT gain value (0-255). - excitation_bandwidth: Excitation filter bandwidth in deci-tenths of nm. - emission_bandwidth: Emission filter bandwidth in deci-tenths of nm. - lag_us: Lag time in microseconds between excitation and measurement. - """ - - if not 230 <= excitation_wavelength <= 850: - raise ValueError("Excitation wavelength must be between 230 nm and 850 nm.") - if not 230 <= emission_wavelength <= 850: - raise ValueError("Emission wavelength must be between 230 nm and 850 nm.") - if focal_height < 0: - raise ValueError("Focal height must be non-negative for fluorescence scans.") - - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) - - await self._begin_run() - try: - await self._configure_fluorescence( - excitation_wavelength, - emission_wavelength, - focal_height, - flashes=flashes, - integration_us=integration_us, - gain=gain, - excitation_bandwidth=excitation_bandwidth, - emission_bandwidth=emission_bandwidth, - lag_us=lag_us, - ) - decoder = _FluorescenceRunDecoder(len(scan_wells)) - - await self._run_scan( - ordered_wells=ordered_wells, - decoder=decoder, - mode="Fluorescence", - step_loss_commands=[ - "CHECK MTP.STEPLOSS", - "CHECK FI.TOP.STEPLOSS", - "CHECK FI.STEPLOSS.Z", - ], - serpentine=True, - scan_direction="UP", - ) - - if len(decoder.intensities) != len(scan_wells): - raise RuntimeError("Fluorescence decoder did not complete scan.") - intensities = decoder.intensities - matrix = self._format_plate_result(plate, scan_wells, intensities) - return [ - { - "ex_wavelength": excitation_wavelength, - "em_wavelength": emission_wavelength, - "time": time.time(), - "temperature": None, - "data": matrix, - } - ] - finally: - await self._end_run() - - async def _configure_fluorescence( - self, - excitation_nm: int, - emission_nm: int, - focal_height: float, - *, - flashes: int, - integration_us: int, - gain: int, - excitation_bandwidth: int, - emission_bandwidth: int, - lag_us: int, - ) -> None: - ex_decitenth = int(round(excitation_nm * 10)) - em_decitenth = int(round(emission_nm * 10)) - reads_number = max(1, flashes) - beam_diameter = self._capability_numeric("FI.TOP", "#BEAM DIAMETER", 3000) - z_position = int(round(focal_height * self.counts_per_mm_z)) - - # UI issues the entire FI configuration twice before PREPARE REF. - for _ in range(2): - await self._send_command("MODE FI.TOP", allow_timeout=True) - await self._clear_mode_settings(excitation=True, emission=True) - await self._send_command( - f"EXCITATION 0,FI,{ex_decitenth},{excitation_bandwidth},0", allow_timeout=True - ) - await self._send_command( - f"EMISSION 0,FI,{em_decitenth},{emission_bandwidth},0", allow_timeout=True - ) - await self._send_command(f"TIME 0,INTEGRATION={integration_us}", allow_timeout=True) - await self._send_command(f"TIME 0,LAG={lag_us}", allow_timeout=True) - await self._send_command("TIME 0,READDELAY=0", allow_timeout=True) - await self._send_command(f"GAIN 0,VALUE={gain}", allow_timeout=True) - await self._send_command(f"POSITION 0,Z={z_position}", allow_timeout=True) - await self._send_command(f"BEAM DIAMETER={beam_diameter}", allow_timeout=True) - await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) - await self._send_command("RATIO LABELS=1", allow_timeout=True) - await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) - await self._send_command( - f"EXCITATION 1,FI,{ex_decitenth},{excitation_bandwidth},0", allow_timeout=True - ) - await self._send_command( - f"EMISSION 1,FI,{em_decitenth},{emission_bandwidth},0", allow_timeout=True - ) - await self._send_command(f"TIME 1,INTEGRATION={integration_us}", allow_timeout=True) - await self._send_command(f"TIME 1,LAG={lag_us}", allow_timeout=True) - await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) - await self._send_command(f"GAIN 1,VALUE={gain}", allow_timeout=True) - await self._send_command(f"POSITION 1,Z={z_position}", allow_timeout=True) - await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) - await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) - - async def read_luminescence( - self, - plate: Plate, - wells: List[Well], - focal_height: float = 20.0, - flashes: int = 25, - dark_integration_us: int = 3_000_000, - meas_integration_us: int = 1_000_000, - ) -> List[Dict]: - """Queue and execute a luminescence scan.""" - - if focal_height < 0: - raise ValueError("Focal height must be non-negative for luminescence scans.") - - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) - - dark_integration = dark_integration_us - meas_integration = meas_integration_us - - await self._begin_run() - try: - await self._configure_luminescence( - dark_integration, meas_integration, focal_height, flashes=flashes - ) - - decoder = _LuminescenceRunDecoder( - len(scan_wells), - dark_integration_s=_integration_microseconds_to_seconds(dark_integration), - meas_integration_s=_integration_microseconds_to_seconds(meas_integration), - ) - - await self._run_scan( - ordered_wells=ordered_wells, - decoder=decoder, - mode="Luminescence", - step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"], - serpentine=False, - scan_direction="UP", - ) - - if len(decoder.measurements) != len(scan_wells): - raise RuntimeError("Luminescence decoder did not complete scan.") - intensities = [measurement.intensity for measurement in decoder.measurements] - matrix = self._format_plate_result(plate, scan_wells, intensities) - return [ - { - "time": time.time(), - "temperature": None, - "data": matrix, - } - ] - finally: - await self._end_run() - - async def _await_measurements( - self, decoder: "_MeasurementDecoder", row_count: int, mode: str - ) -> None: - target = decoder.count + row_count - start_count = decoder.count - self._drain_pending_bin_events(decoder) - start = time.monotonic() - reads = 0 - while decoder.count < target and (time.monotonic() - start) < self._max_row_wait_s: - chunk = await self._read_packet(self._read_chunk_size) - if not chunk: - raise RuntimeError(f"{mode} read returned empty chunk; transport may not support reads.") - decoder.feed(chunk) - reads += 1 - if decoder.count < target: - got = decoder.count - start_count - raise RuntimeError( - f"Timed out while parsing {mode.lower()} results " - f"(decoded {got}/{row_count} measurements in {time.monotonic() - start:.1f}s, {reads} reads)." - ) - - def _drain_pending_bin_events(self, decoder: "_MeasurementDecoder") -> None: - if not self._pending_bin_events: - return - for payload_len, blob in self._pending_bin_events: - decoder.feed_bin(payload_len, blob) - self._pending_bin_events.clear() - - async def _await_scan_terminal(self, saw_terminal: bool) -> None: - if saw_terminal: - return - await self._read_command_response() - - async def _configure_luminescence( - self, - dark_integration: int, - meas_integration: int, - focal_height: float, - *, - flashes: int, - ) -> None: - await self._send_command("MODE LUM") - # Pre-flight safety checks observed in captures (queries omitted). - await self._send_command("CHECK LUM.FIBER") - await self._send_command("CHECK LUM.LID") - await self._send_command("CHECK LUM.STEPLOSS") - await self._send_command("MODE LUM") - reads_number = max(1, flashes) - z_position = int(round(focal_height * self.counts_per_mm_z)) - await self._clear_mode_settings(emission=True) - await self._send_command(f"POSITION LUM,Z={z_position}", allow_timeout=True) - await self._send_command(f"TIME 0,INTEGRATION={dark_integration}", allow_timeout=True) - await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) - await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) - await self._send_command("RATIO LABELS=1", allow_timeout=True) - await self._send_command("EMISSION 1,EMPTY,0,0,0", allow_timeout=True) - await self._send_command(f"TIME 1,INTEGRATION={meas_integration}", allow_timeout=True) - await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) - await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) - await self._send_command("#EMISSION ATTENUATION", allow_timeout=True) - await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) - - def _group_by_row(self, wells: Sequence[Well]) -> List[Tuple[int, List[Well]]]: - grouped: Dict[int, List[Well]] = {} - for well in wells: - grouped.setdefault(well.get_row(), []).append(well) - for row in grouped.values(): - row.sort(key=lambda w: w.get_column()) - return sorted(grouped.items(), key=lambda item: item[0]) - - def _scan_visit_order(self, wells: Sequence[Well], serpentine: bool) -> List[Well]: - visit: List[Well] = [] - for row_index, row_wells in self._group_by_row(wells): - if serpentine and row_index % 2 == 1: - visit.extend(reversed(row_wells)) - else: - visit.extend(row_wells) - return visit - - def _map_well_to_stage(self, well: Well) -> StagePosition: - if well.location is None: - raise ValueError("Well does not have a location assigned within its plate definition.") - center = well.location + well.get_anchor(x="c", y="c") - stage_x = int(round(center.x * self.counts_per_mm_x)) - parent_plate = well.parent - if parent_plate is None or not isinstance(parent_plate, Plate): - raise ValueError("Well is not assigned to a plate; cannot derive stage coordinates.") - plate_height_mm = parent_plate.get_size_y() - stage_y = int(round((plate_height_mm - center.y) * self.counts_per_mm_y)) - return stage_x, stage_y - - def _scan_range( - self, row_index: int, row_wells: Sequence[Well], serpentine: bool - ) -> Tuple[int, int, int]: - """Return start/end/count for a row, honoring serpentine layout when requested.""" - - first_x, _ = self._map_well_to_stage(row_wells[0]) - last_x, _ = self._map_well_to_stage(row_wells[-1]) - count = len(row_wells) - if not serpentine: - return min(first_x, last_x), max(first_x, last_x), count - if row_index % 2 == 0: - return first_x, last_x, count - return last_x, first_x, count - - def _format_plate_result( - self, plate: Plate, wells: Sequence[Well], values: Sequence[float] - ) -> List[List[Optional[float]]]: - matrix: List[List[Optional[float]]] = [ - [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) - ] - for well, val in zip(wells, values): - r, c = well.get_row(), well.get_column() - if 0 <= r < plate.num_items_y and 0 <= c < plate.num_items_x: - matrix[r][c] = float(val) - return matrix - - async def _initialize_device(self) -> None: - try: - await self._send_command("QQ") - except TimeoutError: - logger.warning("QQ produced no response; continuing with initialization.") - await self._send_command("INIT FORCE") - - async def _begin_run(self) -> None: - self._reset_stream_state() - await self._send_command("KEYLOCK ON") - self._run_active = True - - def _reset_stream_state(self) -> None: - self._pending_bin_events.clear() - self._parser = _StreamParser(allow_bare_ascii=True) - - async def _read_packet(self, size: int) -> bytes: - try: - data = await self.io.read(size=size) - except TimeoutError: - await self._recover_transport() - raise - return data - - async def _recover_transport(self) -> None: - try: - await self.io.stop() - await asyncio.sleep(0.2) - await self.io.setup() - except Exception: - logger.warning("Transport recovery failed.", exc_info=True) - return - self._mode_capabilities.clear() - self._reset_stream_state() - await self._initialize_device() - - async def _end_run(self) -> None: - try: - await self._send_command("TERMINATE", allow_timeout=True) - for cmd in self._active_step_loss_commands: - await self._send_command(cmd, allow_timeout=True) - await self._send_command("KEYLOCK OFF", allow_timeout=True) - await self._send_command("ABSOLUTE MTP,IN", allow_timeout=True) - finally: - self._run_active = False - self._active_step_loss_commands = [] - - async def _cleanup_protocol(self) -> None: - async def send_cleanup_cmd(cmd: str) -> None: - try: - await self._send_command(cmd, allow_timeout=True, read_response=False) - except Exception: - logger.warning("Cleanup command failed: %s", cmd) - - if self._run_active or self._active_step_loss_commands: - await send_cleanup_cmd("TERMINATE") - for cmd in self._active_step_loss_commands: - await send_cleanup_cmd(cmd) - await send_cleanup_cmd("KEYLOCK OFF") - await send_cleanup_cmd("ABSOLUTE MTP,IN") - self._run_active = False - self._active_step_loss_commands = [] - - async def _query_mode_capabilities(self, mode: str) -> None: - commands = self._MODE_CAPABILITY_COMMANDS.get(mode) - if not commands: - return - try: - await self._send_command(f"MODE {mode}") - except TimeoutError: - logger.warning("Capability MODE %s timed out; continuing without mode capabilities.", mode) - return - collected: Dict[str, str] = {} - for cmd in commands: - try: - frames = await self._send_command(cmd) - except TimeoutError: - logger.warning("Capability query '%s' timed out; proceeding with defaults.", cmd) - continue - if frames: - collected[cmd] = frames[-1] - if collected: - self._mode_capabilities[mode] = collected - - def _get_mode_capability(self, mode: str, command: str) -> Optional[str]: - return self._mode_capabilities.get(mode, {}).get(command) - - def _capability_numeric(self, mode: str, command: str, fallback: int) -> int: - resp = self._get_mode_capability(mode, command) - if not resp: - return fallback - token = resp.split("|")[0].split(":")[0].split("~")[0].strip() - if not token: - return fallback - try: - return int(float(token)) - except ValueError: - return fallback - - @staticmethod - def _frame_command(command: str) -> bytes: - """Return a framed command with length/checksum trailer.""" - - payload = command.encode("ascii") - xor = 0 - for byte in payload: - xor ^= byte - checksum = (xor ^ 0x01) & 0xFF - length = len(payload) & 0xFF - return b"\x02" + payload + b"\x03\x00\x00" + bytes([length, checksum]) + b"\x0d" - - async def _send_command( - self, - command: str, - wait_for_terminal: bool = True, - allow_timeout: bool = False, - read_response: bool = True, - ) -> List[str]: - logger.debug("[tecan] >> %s", command) - framed = self._frame_command(command) - await self.io.write(framed) - if not read_response: - return [] - if command.startswith(("#", "?")): - try: - return await self._read_command_response(require_terminal=False) - except TimeoutError: - if allow_timeout: - logger.warning("Timeout waiting for response to %s", command) - return [] - raise - try: - frames = await self._read_command_response(require_terminal=wait_for_terminal) - except TimeoutError: - if allow_timeout: - logger.warning("Timeout waiting for response to %s", command) - return [] - raise - for pkt in frames: - logger.debug("[tecan] << %s", pkt) - return frames - - async def _drain(self, attempts: int = 4) -> None: - """Read and discard a few packets to clear the stream.""" - for _ in range(attempts): - data = await self._read_packet(128) - if not data: - break - - async def _read_command_response( - self, max_iterations: int = 8, require_terminal: bool = True - ) -> List[str]: - """Read response frames and cache any binary payloads that arrive.""" - frames: List[str] = [] - saw_terminal = False - for _ in range(max_iterations): - chunk = await self._read_packet(128) - if not chunk: - break - for event in self._parser.feed(chunk): - if event.text is not None: - frames.append(event.text) - if self._is_terminal_frame(event.text): - saw_terminal = True - elif event.payload_len is not None and event.blob is not None: - self._pending_bin_events.append((event.payload_len, event.blob)) - if not require_terminal and frames and not self._parser.has_pending_bin(): - break - if require_terminal and saw_terminal and not self._parser.has_pending_bin(): - break - if require_terminal and not saw_terminal: - # best effort: drain once more so pending ST doesn't leak into next command - await self._drain(1) - return frames - - @staticmethod - def _is_terminal_frame(text: str) -> bool: - """Return True if the ASCII frame is a terminal marker.""" - return text in {"ST", "+", "-"} or text.startswith("BY#T") - - -@dataclass -class _AbsorbanceMeasurement: - sample: int - reference: int - items: Optional[List[Tuple[int, int]]] = None - - -class _AbsorbanceRunDecoder(_MeasurementDecoder): - """Incrementally decode absorbance measurement frames.""" - - STATUS_FRAME_LEN = 31 - - def __init__(self, expected: int) -> None: - super().__init__(expected) - self.measurements: List[_AbsorbanceMeasurement] = [] - self._calibration: Optional[_AbsorbanceCalibration] = None - - @property - def count(self) -> int: - return len(self.measurements) - - @property - def calibration(self) -> Optional[_AbsorbanceCalibration]: - """Return the absorbance calibration data, if available.""" - return self._calibration - - def _should_consume_bin(self, payload_len: int) -> bool: - return _is_abs_calibration_len(payload_len) or _is_abs_data_len(payload_len) - - def _handle_bin(self, payload_len: int, blob: bytes) -> None: - if _is_abs_calibration_len(payload_len): - if self._calibration is not None: - return - cal = _decode_abs_calibration(payload_len, blob) - if cal is not None: - self._calibration = cal - return - if _is_abs_data_len(payload_len): - data = _decode_abs_data(payload_len, blob) - if data is None: - return - _label, _ex, items = data - sample, reference = items[0] if items else (0, 0) - self.measurements.append( - _AbsorbanceMeasurement(sample=sample, reference=reference, items=items) - ) - - -class _FluorescenceRunDecoder(_MeasurementDecoder): - """Incrementally decode fluorescence measurement frames.""" - - STATUS_FRAME_LEN = 31 - - def __init__(self, expected_wells: int) -> None: - super().__init__(expected_wells) - self._intensities: List[int] = [] - self._calibration: Optional[_FluorescenceCalibration] = None - - @property - def count(self) -> int: - return len(self._intensities) - - @property - def intensities(self) -> List[int]: - """Return decoded fluorescence intensities.""" - return self._intensities - - def _should_consume_bin(self, payload_len: int) -> bool: - if payload_len == 18: - return True - if payload_len >= 16 and (payload_len - 6) % 10 == 0: - return True - return False - - def _handle_bin(self, payload_len: int, blob: bytes) -> None: - if payload_len == 18: - cal = _decode_flr_calibration(payload_len, blob) - if cal is not None: - self._calibration = cal - return - data = _decode_flr_data(payload_len, blob) - if data is None: - return - _label, _ex, _em, items = data - if self._calibration is not None: - intensity = _fluorescence_corrected(self._calibration, items) - else: - if not items: - intensity = 0 - else: - intensity = int(round(sum(m for m, _ in items) / len(items))) - self._intensities.append(intensity) - - -@dataclass -class _LuminescenceMeasurement: - intensity: int - - -class _LuminescenceRunDecoder(_MeasurementDecoder): - """Incrementally decode luminescence measurement frames.""" - - def __init__( - self, - expected: int, - *, - dark_integration_s: float = 0.0, - meas_integration_s: float = 0.0, - ) -> None: - super().__init__(expected) - self.measurements: List[_LuminescenceMeasurement] = [] - self._calibration: Optional[_LuminescenceCalibration] = None - self._dark_integration_s = float(dark_integration_s) - self._meas_integration_s = float(meas_integration_s) - - @property - def count(self) -> int: - return len(self.measurements) - - def _should_consume_bin(self, payload_len: int) -> bool: - if payload_len == 10: - return True - if payload_len >= 14 and (payload_len - 4) % 10 == 0: - return True - return False - - def _handle_bin(self, payload_len: int, blob: bytes) -> None: - if payload_len == 10: - cal = _decode_lum_calibration(payload_len, blob) - if cal is not None: - self._calibration = cal - return - data = _decode_lum_data(payload_len, blob) - if data is None: - return - _label, _em, counts = data - if self._calibration is not None and self._dark_integration_s and self._meas_integration_s: - intensity = _luminescence_intensity( - self._calibration, counts, self._dark_integration_s, self._meas_integration_s - ) - else: - intensity = int(round(sum(counts) / len(counts))) if counts else 0 - self.measurements.append(_LuminescenceMeasurement(intensity=intensity)) - - -__all__ = [ - "ExperimentalTecanInfinite200ProBackend", -] 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/plate_washing/biotek/el406/actions.py b/pylabrobot/plate_washing/biotek/el406/actions.py deleted file mode 100644 index 870fa27bca5..00000000000 --- a/pylabrobot/plate_washing/biotek/el406/actions.py +++ /dev/null @@ -1,162 +0,0 @@ -"""EL406 action and control methods. - -This module contains the mixin class for action/control operations on the -BioTek EL406 plate washer (reset, home, pause, resume, etc.). -""" - -from __future__ import annotations - -import logging - -from .communication import LONG_READ_TIMEOUT -from .enums import ( - EL406Motor, - EL406MotorHomeType, - EL406StepType, - EL406WasherManifold, -) -from .protocol import build_framed_message - -logger = logging.getLogger(__name__) - - -class EL406ActionsMixin: - """Mixin providing action/control methods for the EL406. - - This mixin provides: - - Abort, pause, resume operations - - Reset instrument - - Home/verify motors - - End-of-batch operations - - Auto-prime operations - - Set washer manifold - - Requires: - self._send_framed_command: Async method for sending framed commands - self._send_action_command: Async method for sending action commands - """ - - async def _send_framed_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - raise NotImplementedError - - async def _send_action_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - raise NotImplementedError - - 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) diff --git a/pylabrobot/plate_washing/biotek/el406/backend.py b/pylabrobot/plate_washing/biotek/el406/backend.py deleted file mode 100644 index 15bb6b833f1..00000000000 --- a/pylabrobot/plate_washing/biotek/el406/backend.py +++ /dev/null @@ -1,199 +0,0 @@ -"""BioTek EL406 plate washer backend. - -This module provides the backend implementation for the BioTek EL406 -plate washer, communicating via FTDI USB serial interface. - -Protocol Details: -- Serial: 38400 baud, 8 data bits, 2 stop bits, no parity -- Flow control: disabled (no flow control) -- ACK byte: 0x06 -- Commands are binary with little-endian encoding -- Read timeout: 15000ms, Write timeout: 5000ms -""" - -from __future__ import annotations - -import asyncio -import logging -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from pylabrobot.io.ftdi import FTDI -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.resources import Plate - -from .actions import EL406ActionsMixin -from .communication import EL406CommunicationMixin -from .errors import EL406CommunicationError -from .helpers import plate_to_wire_byte -from .queries import EL406QueriesMixin -from .steps import EL406StepsMixin - -logger = logging.getLogger(__name__) - - -class ExperimentalBioTekEL406Backend( - EL406CommunicationMixin, - EL406QueriesMixin, - EL406ActionsMixin, - EL406StepsMixin, - MachineBackend, -): - """Backend for BioTek EL406 plate washer. - - Communicates with the EL406 via FTDI USB interface. - - Attributes: - timeout: Default timeout for operations in seconds. - - Example: - >>> backend = BioTekEL406Backend() - >>> await backend.setup() - >>> await backend.peristaltic_prime(plate, volume=300.0, flow_rate="High") - >>> await backend.manifold_wash(plate, cycles=3) - """ - - def __init__( - self, - timeout: float = 15.0, - device_id: str | None = None, - ) -> None: - """Initialize the EL406 backend. - - Args: - timeout: Default timeout for operations in seconds. - device_id: FTDI device serial number for explicit connection. - """ - super().__init__() - self.timeout = timeout - self._device_id = device_id - self.io: FTDI | None = None - self._command_lock: asyncio.Lock | None = None - self._in_batch: bool = False - - 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. - - Note: This does NOT start a batch. Use ``batch()`` or call step commands - directly (they auto-batch). - - Args: - skip_reset: If True, skip the instrument reset step. - - Raises: - RuntimeError: If pylibftdi is not installed or communication fails. - """ - self._command_lock = asyncio.Lock() - - logger.info("BioTekEL406Backend setting up") - logger.info(" Timeout: %.1f seconds", self.timeout) - - if self.io is None: - self.io = FTDI(human_readable_device_name="BioTek EL406", device_id=self._device_id) - 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() - self.io = None - 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("BioTekEL406Backend setup complete") - - async def stop(self) -> None: - """Stop communication with the EL406. - - Closes the FTDI connection. Batch cleanup is handled by the ``batch()`` - context manager, not by ``stop()``. - """ - logger.info("BioTekEL406Backend stopping") - if self.io is not None: - await self.io.stop() - self.io = None - - @asynccontextmanager - async def batch(self, plate: Plate) -> AsyncIterator[None]: - """Context manager for batching step commands. - - Each step command (manifold_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. - - Args: - plate: PLR Plate to configure for this batch. - - Example: - >>> async with backend.batch(plate_96): - ... await backend.manifold_prime(plate_96, volume=5000) - ... await backend.manifold_wash(plate_96, cycles=3) - ... await backend.syringe_dispense(plate_96, volume=50) - """ - if self._in_batch: - yield - return - - self._in_batch = True - try: - await self.start_batch(plate_to_wire_byte(plate)) - yield - finally: - try: - await self.cleanup_after_protocol() - finally: - self._in_batch = False - - def serialize(self) -> dict: - """Serialize backend configuration.""" - return { - **super().serialize(), - "timeout": self.timeout, - "device_id": self._device_id, - } diff --git a/pylabrobot/plate_washing/biotek/el406/queries.py b/pylabrobot/plate_washing/biotek/el406/queries.py deleted file mode 100644 index 5daf5c1ecea..00000000000 --- a/pylabrobot/plate_washing/biotek/el406/queries.py +++ /dev/null @@ -1,185 +0,0 @@ -"""EL406 query methods. - -This module contains the mixin class for query operations on the -BioTek EL406 plate washer. -""" - -from __future__ import annotations - -import enum -import logging -from typing import TypedDict, TypeVar - -from .communication import LONG_READ_TIMEOUT -from .enums import ( - EL406Sensor, - EL406SyringeManifold, - EL406WasherManifold, -) - -logger = logging.getLogger(__name__) - -_E = TypeVar("_E", bound=enum.Enum) - - -class SyringeBoxInfo(TypedDict): - box_type: int - box_size: int - installed: bool - - -class SelfCheckResult(TypedDict): - success: bool - error_code: int - message: str - - -class InstrumentSettings(TypedDict): - washer_manifold: EL406WasherManifold - syringe_manifold: EL406SyringeManifold - syringe_box: SyringeBoxInfo - peristaltic_pump_1: bool - peristaltic_pump_2: bool - - -class EL406QueriesMixin: - """Mixin providing query methods for the EL406. - - This mixin provides: - - Manifold queries (washer, syringe) - - Serial number query - - Sensor status query - - Syringe box info query - - Peristaltic pump installation query - - Instrument settings query - - Self-check query - - Requires: - self._send_framed_query: Async method for sending framed queries - """ - - async def _send_framed_query( - self, - command: int, - data: bytes = b"", - timeout: float | None = None, - ) -> bytes: - raise NotImplementedError - - @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] - - 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 - - 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 = 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 - - 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 = 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 - - 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 = SelfCheckResult(success=success, error_code=error_code, message=message) - logger.info("Self-check result: %s", result["message"]) - return result diff --git a/pylabrobot/plate_washing/biotek/el406/steps/__init__.py b/pylabrobot/plate_washing/biotek/el406/steps/__init__.py deleted file mode 100644 index 07224cd005b..00000000000 --- a/pylabrobot/plate_washing/biotek/el406/steps/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""EL406 protocol step methods. - -This package contains the mixin class for protocol step operations on the -BioTek EL406 plate washer (prime, dispense, aspirate, wash, shake, etc.). - -The methods are split into per-subsystem modules for maintainability, but -the composed ``EL406StepsMixin`` class is the only public API. -""" - -from ._manifold import EL406ManifoldStepsMixin -from ._peristaltic import EL406PeristalticStepsMixin -from ._shake import EL406ShakeStepsMixin -from ._syringe import EL406SyringeStepsMixin - - -class EL406StepsMixin( - EL406PeristalticStepsMixin, - EL406SyringeStepsMixin, - EL406ManifoldStepsMixin, - EL406ShakeStepsMixin, -): - """Mixin providing all protocol step methods for the EL406. - - This class composes all per-subsystem step mixins: - - Peristaltic: peristaltic_prime, peristaltic_dispense, peristaltic_purge - - Syringe: syringe_dispense, syringe_prime - - Manifold: manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, manifold_auto_clean - - Shake: shake - - Requires: - self._send_step_command: Async method for sending framed commands - self.timeout: Default timeout in seconds - """ diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_base.py b/pylabrobot/plate_washing/biotek/el406/steps/_base.py deleted file mode 100644 index b94292144dd..00000000000 --- a/pylabrobot/plate_washing/biotek/el406/steps/_base.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Base mixin providing type stubs for EL406 step sub-mixins. - -Sub-mixins inherit from this class so they can reference -``self._send_step_command`` and ``self.timeout`` without circular imports. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pylabrobot.resources import Plate - - -class EL406StepsBaseMixin: - """Type stubs consumed by the per-subsystem step mixins.""" - - timeout: float - - if TYPE_CHECKING: - - async def _send_step_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: ... - - @asynccontextmanager - async def batch(self, plate: Plate) -> AsyncIterator[None]: - yield 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/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py deleted file mode 100644 index 1ba4da80908..00000000000 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py +++ /dev/null @@ -1,217 +0,0 @@ -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 - -from pylabrobot.pumps.backend import PumpArrayBackend - -logger = logging.getLogger("pylabrobot") - - -class AgrowPumpArrayBackend(PumpArrayBackend): - """ - AgrowPumpArray allows users to control AgrowPumps via Modbus communication. - - https://www.agrowtek.com/doc/im/IM_MODBUS.pdf - https://agrowtek.com/doc/im/IM_LX1.pdf - - Attributes: - port: The port that the AgrowPumpArray is connected to. - address: The address of the AgrowPumpArray client registers. - - Properties: - num_channels: The number of channels that the AgrowPumpArray has. - pump_index_to_address: A dictionary that maps pump indices to their Modbus addresses. - """ - - def __init__(self, port: str, address: Union[int, str]): - 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 - - @property - def modbus(self) -> "AsyncModbusSerialClient": - """Returns the Modbus connection to the AgrowPumpArray.""" - if self._modbus is None: - raise RuntimeError("Modbus connection not established") - return self._modbus - - @property - def pump_index_to_address(self) -> Dict[int, int]: - """Returns a dictionary that maps pump indices to their Modbus addresses. - - Returns: - Dict[int, int]: A dictionary that maps pump indices to their Modbus addresses. - """ - - 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: - """The number of channels that the AgrowPumpArray has. - - Returns: - The number of channels that the AgrowPumpArray has. - """ - if self._num_channels is None: - raise RuntimeError("Number of channels not established") - return self._num_channels - - def start_keep_alive_thread(self): - """Creates a daemon thread that sends a Modbus request every 25 seconds to keep the connection - alive.""" - - async def keep_alive(): - """Sends a Modbus request every 25 seconds to keep the connection alive. - Sleep for 0.1 seconds so we can respond to `stop` events fast. - """ - 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) - i = 0 - - def manage_async_keep_alive(): - """Manages the keep alive thread.""" - 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("Error in keep alive thread: %s", 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): - """Sets up the Modbus connection to the AgrowPumpArray and creates the - pump mappings needed to issue commands. - """ - await self._setup_modbus() - register_return = await self.modbus.read_holding_registers(19, 2, unit=self.address) - 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)} - - 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, - ) - await self.modbus.connect() - if not self.modbus.connected: - raise ConnectionError("Modbus connection failed during pump setup") - - def serialize(self): - return { - **super().serialize(), - "port": self.port, - "address": self.address, - } - - async def run_revolutions(self, num_revolutions: List[float], use_channels: List[int]): - """Run the specified channels at the speed selected. If speed is 0, the pump will be halted. - - Args: - num_revolutions: number of revolutions to run pumps. - use_channels: pump array channels to run - - Raises: - NotImplementedError: Revolution based pumping commands are not available for this array. - """ - - raise NotImplementedError( - "Revolution based pumping commands are not available for this pump array." - ) - - async def run_continuously(self, speed: List[float], use_channels: List[int]): - """Run pumps at the specified speeds. - - Args: - speed: rate at which to run pump. - use_channels: pump array channels to run - - Raises: - ValueError: Pump address out of range - ValueError: Pump speed out of range - """ - - for pump_index, pump_speed in zip(use_channels, speed): - pump_speed = int(pump_speed) - if pump_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[pump_index], - pump_speed, - unit=self.address, - ) - - async def halt(self): - """Halt the entire pump array.""" - assert self.modbus is not None, "Modbus connection not established" - assert self.pump_index_to_address is not None, "Pump address mapping not established" - logger.info("Halting pump array") - for pump in self.pump_index_to_address: - address = self.pump_index_to_address[pump] - await self.modbus.write_register(address, 0, unit=self.address) - - async def stop(self): - """Close the connection to the pump array.""" - await self.halt() - assert self.modbus is not None, "Modbus connection not established" - 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" - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class AgrowPumpArray: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`AgrowPumpArray` is deprecated. Please use `AgrowPumpArrayBackend` instead." - ) diff --git a/pylabrobot/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/pumps/cole_parmer/masterflex_backend.py deleted file mode 100644 index 41cdf5b24a0..00000000000 --- a/pylabrobot/pumps/cole_parmer/masterflex_backend.py +++ /dev/null @@ -1,91 +0,0 @@ -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 -from pylabrobot.pumps.backend import PumpBackend - - -class MasterflexBackend(PumpBackend): - """Backend for the Cole Parmer Masterflex L/S pump - - 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): - 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") - - def serialize(self): - return {**super().serialize(), "com_port": self.com_port} - - async def stop(self): - await self.io.stop() - - async def send_command(self, command: str): - command = "\x02P02" + command + "\x0d" - await self.io.write(command.encode()) - return self.io.read() - - async def run_revolutions(self, num_revolutions: float): - num_revolutions = round(num_revolutions, 2) - cmd = f"V{num_revolutions}G" - await self.send_command(cmd) - - async def run_continuously(self, speed: float): - if speed == 0: - self.halt() - return - - direction = "+" if speed > 0 else "-" - speed = int(abs(speed)) - cmd = f"S{direction}{speed}G0" - await self.send_command(cmd) - - async def halt(self): - await self.send_command("H") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class Masterflex: - def __init__(self, *args, **kwargs): - raise RuntimeError("`Masterflex` is deprecated. Please use `MasterflexBackend` instead.") diff --git a/pylabrobot/pumps/pumparray.py b/pylabrobot/pumps/pumparray.py deleted file mode 100644 index daedb626a46..00000000000 --- a/pylabrobot/pumps/pumparray.py +++ /dev/null @@ -1,198 +0,0 @@ -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 - - -class PumpArray(Machine): - """Front-end for a pump array. - - Attributes: - backend: The backend that the pump array is controlled through. - calibration: The calibration of the pump. - - Properties: - num_channels: The number of channels that the pump array has. - """ - - def __init__( - self, - backend: PumpArrayBackend, - calibration: Optional[PumpCalibration] = None, - ): - super().__init__(backend=backend) - self.backend: PumpArrayBackend = backend # fix type - self.calibration = calibration - - @property - def num_channels(self) -> int: - """Returns the number of channels that the pump array has. - - Returns: - int: The number of channels that the pump array has. - """ - - return self.backend.num_channels - - def serialize(self) -> dict: - if self.calibration is None: - return super().serialize() - return { - **super().serialize(), - "calibration": self.calibration.serialize(), - } - - @classmethod - def deserialize(cls, data: dict): - data_copy = data.copy() - calibration_data = data_copy.pop("calibration", None) - if calibration_data is not None: - calibration = PumpCalibration.deserialize(calibration_data) - data_copy["calibration"] = calibration - return super().deserialize(data_copy) - - async def run_revolutions( - self, - num_revolutions: Union[float, List[float]], - use_channels: Union[int, List[int]], - ): - """Run the specified channels for the specified number of revolutions. - - Args: - num_revolutions: number of revolutions to run pumps. - use_channels: pump array channels to run. - """ - - if isinstance(use_channels, int): - use_channels = [use_channels] - if isinstance(num_revolutions, float): - num_revolutions = [num_revolutions] * len(use_channels) - await self.backend.run_revolutions(num_revolutions=num_revolutions, use_channels=use_channels) - - async def run_continuously( - self, - speed: Union[float, int, List[float], List[int]], - use_channels: Union[int, List[int]], - ): - """Run the specified channels at the specified speeds. - - Args: - speed: speed in rpm/pump-specific units. - use_channels: pump array channels to run. - """ - - if isinstance(use_channels, list) and len(set(use_channels)) != len(use_channels): - raise ValueError("Channels in use channels must be unique.") - if isinstance(use_channels, int): - use_channels = [use_channels] - if isinstance(speed, (float, int)): - speed = [speed] * len(use_channels) - - if any(channel not in range(0, self.num_channels) for channel in use_channels): - raise ValueError( - f"Pump address out of range for this pump array. \ - Value should be between 0 and {self.num_channels}" - ) - if any(speed < 0 for speed in speed): - raise ValueError("Speed must be positive.") - if isinstance(speed[0], int): - speed = [float(x) for x in speed] - if len(speed) != len(use_channels): - raise ValueError("Speed and use_channels must be the same length.") - if any(channel < 0 for channel in use_channels): - raise ValueError("Channels in use channels must be positive.") - - await self.backend.run_continuously( - speed=speed, # type: ignore[arg-type] - use_channels=use_channels, - ) - - async def run_for_duration( - self, - speed: Union[float, int, List[float], List[int]], - use_channels: Union[int, List[int]], - duration: Union[float, int], - ): - """Run the specified channels at the specified speeds for the specified duration. - - Args: - speed: speed in rpm/pump-specific units. - use_channels: pump array channels to run. - duration: duration to run pumps (seconds). - """ - - if duration < 0: - raise ValueError("Duration must be positive.") - await self.run_continuously(speed=speed, use_channels=use_channels) - await asyncio.sleep(duration) - await self.run_continuously(speed=0, use_channels=use_channels) - - async def pump_volume( - self, - speed: Union[float, int, List[float], List[int]], - use_channels: Union[int, List[int]], - volume: Union[float, int, List[float], List[int]], - ): - """Run the specified channels at the specified speeds for the specified volume. Note that this - function requires the pump to be calibrated at the input speed. - - Args: - speed: speed in rpm/pump-specific units. use_channels: pump array channels to run using - 0-index. volume: volume to pump. - calibration_mode: units of calibration. Volume per seconds ("duration") or volume per - revolution ("revolutions"). - - Raises: - NotCalibratedError: if the pump is not calibrated. - """ - - if self.calibration is None: - raise NotCalibratedError( - "Pump is not calibrated. Volume based pumping and related functions unavailable." - ) - if isinstance(use_channels, int): - use_channels = [use_channels] - if isinstance(speed, (float, int)): - speed = [speed] * len(use_channels) - if isinstance(volume, (float, int)): - volume = [volume] * len(use_channels) - if not all(vol >= 0 for vol in volume): - raise ValueError("Volume must be positive.") - if not len(speed) == len(use_channels) == len(volume): - raise ValueError("Speed, use_channels, and volume must be the same length.") - if self.calibration.calibration_mode == "duration": - durations = [ - channel_volume / self.calibration[channel] - for channel, channel_volume in zip(use_channels, volume) - ] - tasks = [ - asyncio.create_task( - self.run_for_duration( - speed=channel_speed, - use_channels=channel, - duration=duration, - ) - ) - for channel_speed, channel, duration in zip(speed, use_channels, durations) - ] - elif self.calibration.calibration_mode == "revolutions": - num_rotations = [ - channel_volume / self.calibration[channel] - for channel, channel_volume in zip(use_channels, volume) - ] - tasks = [ - asyncio.create_task( - self.run_revolutions(num_revolutions=num_rotation, use_channels=channel) - ) - for num_rotation, channel in zip(num_rotations, use_channels) - ] - else: - raise ValueError("Calibration mode must be 'duration' or 'revolutions'.") - await asyncio.gather(*tasks) - - async def halt(self): - """Halt the entire pump array.""" - await self.backend.halt() diff --git a/pylabrobot/qinstruments/__init__.py b/pylabrobot/qinstruments/__init__.py new file mode 100644 index 00000000000..8108891b766 --- /dev/null +++ b/pylabrobot/qinstruments/__init__.py @@ -0,0 +1,18 @@ +from .bioshake import ( + BioShake, + BioShake3000, + BioShake3000Elm, + BioShake3000ElmDWP, + BioShake3000T, + BioShake3000TElm, + BioShake5000Elm, + BioShakeD30Elm, + BioShakeD30TElm, + BioShakeDriver, + BioShakeQ1, + BioShakeQ2, + BioShakeShakerBackend, + BioShakeTemperatureBackend, + ColdPlate, + Heatplate, +) diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py new file mode 100644 index 00000000000..b127cd5dd7b --- /dev/null +++ b/pylabrobot/qinstruments/bioshake.py @@ -0,0 +1,440 @@ +import asyncio +import logging +from dataclasses import dataclass +from typing import Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.shaking import Shaker, ShakerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureController, + TemperatureControllerBackend, +) +from pylabrobot.device import Device, Driver +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 BioShakeDriver(Driver): + """Serial driver for QInstruments BioShake devices. + + Owns the serial connection, command protocol, and device-level operations + (reset, home) that don't belong to any capability. + """ + + def __init__(self, port: str, timeout: int = 60): + super().__init__() + if not HAS_SERIAL: + raise RuntimeError(f"pyserial is required for BioShake. Import error: {_SERIAL_IMPORT_ERROR}") + + 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, + ) + + 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 + + @dataclass + class SetupParams(BackendParams): + """BioShake-specific parameters for ``setup``. + + Args: + skip_home: If True, skip the reset and home steps during setup. + """ + + skip_home: bool = False + + async def setup(self, backend_params: Optional[BackendParams] = None): + if not isinstance(backend_params, BioShakeDriver.SetupParams): + backend_params = BioShakeDriver.SetupParams() + + await self.io.setup() + if not backend_params.skip_home: + 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): + await self.send_command(cmd="shakeGoHome", delay=5) + + +class BioShakeShakerBackend(ShakerBackend, HasContinuousShaking): + """Translates ShakerBackend calls into BioShake serial commands.""" + + def __init__(self, driver: BioShakeDriver): + self.driver = driver + + async def shake(self, speed: float, duration: float, backend_params=None): + 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 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.driver.send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self.driver.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.driver.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.driver.send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_accel = int(float(await self.driver.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.driver.send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) + logger.info( + "[BioShake %s] start shaking: speed=%d, accel=%d", self.driver.port, speed, acceleration + ) + await self.driver.send_command(cmd="shakeOn", delay=0.2) + + async def stop_shaking(self, deceleration: Union[int, float] = 0): + 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.driver.send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_decel = int(float(await self.driver.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.driver.send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) + logger.info("[BioShake %s] stop shaking (decel=%d)", self.driver.port, deceleration) + await self.driver.send_command(cmd="shakeOff", delay=0.2) + + # The firmware needs the motor to fully decelerate before ELM can operate. + await asyncio.sleep(3) + + @property + def supports_locking(self) -> bool: + return True + + async def lock_plate(self): + logger.info("[BioShake %s] lock plate", self.driver.port) + await self.driver.send_command(cmd="setElmLockPos", delay=0.3) + + async def unlock_plate(self): + logger.info("[BioShake %s] unlock plate", self.driver.port) + await self.driver.send_command(cmd="setElmUnlockPos", delay=0.3) + + +class BioShakeTemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend calls into BioShake serial commands.""" + + def __init__(self, driver: BioShakeDriver, supports_active_cooling: bool = False): + self.driver = driver + self._supports_active_cooling = supports_active_cooling + + @property + def supports_active_cooling(self) -> bool: + return self._supports_active_cooling + + async def set_temperature(self, temperature: float): + min_temp = int(float(await self.driver.send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self.driver.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.driver.port, temperature) + await self.driver.send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) + await self.driver.send_command(cmd="tempOn", delay=0.2) + + async def request_current_temperature(self) -> float: + response = await self.driver.send_command(cmd="getTempActual", delay=0.2) + temp = float(response) + logger.info("[BioShake %s] read temperature: actual=%.1f C", self.driver.port, temp) + return temp + + async def deactivate(self): + logger.info("[BioShake %s] deactivating temperature", self.driver.port) + await self.driver.send_command(cmd="tempOff", delay=0.2) + + +class BioShake(PlateHolder, Device): + """QInstruments BioShake device. + + Use a model-specific factory function (e.g. ``BioShake3000``) to create instances. + ``shaker`` and ``tc`` are ``None`` when the hardware doesn't support the capability. + """ + + def __init__( + self, + name: str, + port: str, + size_x: float, + size_y: float, + size_z: float, + child_location: Coordinate, + pedestal_size_z: float, + has_shaking: bool = False, + has_temperature: bool = False, + supports_active_cooling: bool = False, + category: str = "bioshake", + model: Optional[str] = None, + ): + driver = BioShakeDriver(port=port) + 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=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.driver: BioShakeDriver = driver + + self.shaker: Optional[Shaker] = None + self.tc: Optional[TemperatureController] = None + self._capabilities = [] + + if has_shaking: + self.shaker = Shaker(backend=BioShakeShakerBackend(driver)) + self._capabilities.append(self.shaker) + if has_temperature: + self.tc = TemperatureController( + backend=BioShakeTemperatureBackend(driver, supports_active_cooling=supports_active_cooling) + ) + self._capabilities.append(self.tc) + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } + + +# -- 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, + has_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, + has_shaking=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, + has_shaking=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, + has_shaking=True, + has_temperature=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 d301f54f40c..b12e58da699 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, }, ) @@ -209,7 +201,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) @@ -218,7 +210,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 @@ -235,7 +227,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, @@ -247,7 +239,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, @@ -257,7 +249,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( @@ -268,7 +260,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 cd09709aaec..c599a5d0fd7 100644 --- a/pylabrobot/resources/corning/axygen/plates.py +++ b/pylabrobot/resources/corning/axygen/plates.py @@ -14,7 +14,7 @@ def Cor_Axy_24_wellplate_10mL_Vb(name: str, with_lid: bool = False) -> 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 3d7998a8de1..7bf526ad676 100644 --- a/pylabrobot/resources/hamilton/hamilton_deck_tests.py +++ b/pylabrobot/resources/hamilton/hamilton_deck_tests.py @@ -79,9 +79,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 4f7acc2ec8b..33b64e2c7a1 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 042052099a6..f51b593ddd8 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -90,24 +90,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"], - 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 51923c65ed7..95be0855593 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -107,6 +107,12 @@ def __init__( if lid is not None: self.assign_child_resource(lid) + def serialize(self) -> dict: + data = super().serialize() + if self.plate_type != "skirted": + data["plate_type"] = self.plate_type + return data + @property def lid(self) -> Optional[Lid]: return self._lid @@ -158,12 +164,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 has_lid(self) -> bool: 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 2d03af454f0..922bfee91e7 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: @@ -750,15 +759,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)) diff --git a/pylabrobot/resources/resource_stack.py b/pylabrobot/resources/resource_stack.py index b66b4b87632..be788fc9979 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 eaa461f4a40..dae1b2a6590 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). """ @@ -313,7 +313,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 149b90c445a..9c0c94eca1b 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 668c78cbae3..f2a1b15928b 100644 --- a/pylabrobot/resources/tip_tests.py +++ b/pylabrobot/resources/tip_tests.py @@ -61,4 +61,4 @@ 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) 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/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/sealing/a4s_backend.py b/pylabrobot/sealing/a4s_backend.py deleted file mode 100644 index 5c07cb4a333..00000000000 --- a/pylabrobot/sealing/a4s_backend.py +++ /dev/null @@ -1,230 +0,0 @@ -import asyncio -import dataclasses -import enum -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 -from pylabrobot.sealing.backend import SealerBackend - - -class A4SBackend(SealerBackend): - def __init__(self, port: str, timeout=20) -> 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.port = port - self.timeout = timeout - self.io = Serial( - port=self.port, - baudrate=19200, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - human_readable_device_name="A4S Sealer", - ) - - async def setup(self): - await self.io.setup() - await self.system_reset() - - async def stop(self): - await self.set_heater(on=False) - await self.io.stop() - - async def set_heater(self, on: bool): - """Set the heater on or off.""" - command = "*00H1ZZ" if on else "*00H0ZZ" - await self.send_command(command) - return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) - - @dataclasses.dataclass - class Status: - 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 - # no_connect: bool - - current_temperature: float - system_status: SystemStatus - heater_block_status: HeaterBlockStatus - error_code: int - warning_code: int - sensor_status: SensorStatus - remaining_time: int - - async def _read_message(self) -> str: - """read a message. we are not sure what format it is.""" - 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") - - async def get_status(self) -> Status: - # read until we get a system status message - message: str - while True: - message = await self._read_message() - if message[1] == "T": # read system status - break - # message[1] == b"D": # Operation Status Message Format - # message[1] == b"Y": # Response of Command Accepted Message Format - # message[1] == b"N": # Response of Command Rejected Message Format - # message[1] == b"X": # Communication Busy - - # parsing response - message = message.split("!")[0] - parameters = message[:-4].split("=")[1].split(",") - - error_code = int(str(parameters[3])) # 0 is good - if error_code != 0: - raise RuntimeError(f"An error occurred: response {message}") - - sensor_status = int(str(parameters[5])) - - return A4SBackend.Status( - current_temperature=int(str(parameters[0])) / 10, - system_status=A4SBackend.Status.SystemStatus(int(str(parameters[1]))), - heater_block_status=A4SBackend.Status.HeaterBlockStatus(int(str(parameters[2]))), - error_code=error_code, - warning_code=int(str(parameters[4])), - sensor_status=A4SBackend.Status.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, - # no_connect = sensor_status & 0x0080 != 0, - ), - remaining_time=int(str(parameters[6])), - ) - - async def _wait_for_status(self, statuses: Set["A4SBackend.Status.SystemStatus"]) -> Status: - start = time.time() - while True: - status = await self.get_status() - - if status.system_status == A4SBackend.Status.SystemStatus.error: - 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 send_command(self, command: str): - # command accepted: *Y01PL! - # Command index: 01 - await self.io.write(command.encode()) - await asyncio.sleep(0.1) - - async def seal(self, temperature: int, duration: float): - await self.set_temperature(temperature) - await self.set_time(duration) - await self.send_command("*00GS=zz!") # Command to conduct seal action - await self._wait_for_status({A4SBackend.Status.SystemStatus.single_cycle}) - return await self._wait_for_status( - {A4SBackend.Status.SystemStatus.idle, A4SBackend.Status.SystemStatus.finish} - ) - - async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): - start = time.time() - while True: - current_temperature = await self.get_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 _wait_for_shuttle_open_sensor( - self, shuttle_open: bool, timeout: float = 30.0 - ) -> Status: - start = time.time() - while True: - status = await self.get_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 set_temperature(self, temperature: float): - if not (50 <= temperature <= 200): - raise ValueError("Temperature out of range. Please enter a value between 50 and 200.") - command = f"*00DH={round(temperature):04d}zz!" - await self.send_command(command) - await self._wait_for_temperature(temperature, timeout=300) - - 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 open(self) -> Status: - await self.send_command("*00MO=zz!") - return await self._wait_for_shuttle_open_sensor(True) - - async def close(self) -> Status: - await self.send_command("*00MC=zz!") - return await self._wait_for_shuttle_open_sensor(False) - - async def system_reset(self): - await self.send_command("*00SR=zz!") - return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) - - async def get_temperature(self) -> float: - status = await self.get_status() - return status.current_temperature - - async def get_remaining_time(self) -> int: - status = await self.get_status() - return status.remaining_time diff --git a/pylabrobot/sealing/sealer.py b/pylabrobot/sealing/sealer.py deleted file mode 100644 index 3b3c027ac8e..00000000000 --- a/pylabrobot/sealing/sealer.py +++ /dev/null @@ -1,28 +0,0 @@ -from pylabrobot.machines import Machine - -from .backend import SealerBackend - - -class Sealer(Machine): - """A microplate sealer""" - - def __init__(self, backend: SealerBackend): - super().__init__(backend=backend) - self.backend: SealerBackend = backend # fix type - - async def seal(self, temperature: int, duration: float): - return await self.backend.seal(temperature=temperature, duration=duration) - - async def open(self): - return await self.backend.open() - - async def close(self): - return await self.backend.close() - - async def set_temperature(self, temperature: float): - """Set the temperature of the sealer in degrees Celsius.""" - return await self.backend.set_temperature(temperature=temperature) - - async def get_temperature(self) -> float: - """Get the current temperature of the sealer in degrees Celsius.""" - return await self.backend.get_temperature() 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/shaking/shaker.py b/pylabrobot/shaking/shaker.py deleted file mode 100644 index a503279e5d4..00000000000 --- a/pylabrobot/shaking/shaker.py +++ /dev/null @@ -1,69 +0,0 @@ -import asyncio -from typing import Optional - -from pylabrobot.machines.machine import Machine -from pylabrobot.resources import Coordinate, ResourceHolder - -from .backend import ShakerBackend - - -class Shaker(ResourceHolder, Machine): - """A shaker machine""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: ShakerBackend, - child_location: Coordinate, - category: str = "shaker", - model: Optional[str] = None, - ): - ResourceHolder.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - child_location=child_location, - ) - Machine.__init__(self, backend=backend) - self.backend: ShakerBackend = backend # fix type - - async def shake(self, speed: float, duration: Optional[float] = None, **backend_kwargs): - """Shake the shaker at the given speed - - Args: - speed: Speed of shaking in revolutions per minute (RPM) - duration: Duration of shaking in seconds. If None, shake indefinitely (and return immediately). - """ - if self.backend.supports_locking: - await self.backend.lock_plate() - await self.backend.start_shaking(speed=speed, **backend_kwargs) - - if duration is None: - return - - await asyncio.sleep(duration) - await self.backend.stop_shaking() - if self.backend.supports_locking: - await self.backend.unlock_plate() - - async def stop_shaking(self, **backend_kwargs): - await self.backend.stop_shaking(**backend_kwargs) - - async def lock_plate(self, **backend_kwargs): - await self.backend.lock_plate(**backend_kwargs) - - async def unlock_plate(self, **backend_kwargs): - await self.backend.unlock_plate(**backend_kwargs) - - def serialize(self) -> dict: - return { - **Machine.serialize(self), - **ResourceHolder.serialize(self), - } 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/storage/cytomat/__init__.py b/pylabrobot/storage/cytomat/__init__.py deleted file mode 100644 index 9a6315180ef..00000000000 --- a/pylabrobot/storage/cytomat/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .cytomat import CytomatBackend, CytomatChatterbox, CytomatType diff --git a/pylabrobot/tecan/__init__.py b/pylabrobot/tecan/__init__.py new file mode 100644 index 00000000000..f4cd5db5c8a --- /dev/null +++ b/pylabrobot/tecan/__init__.py @@ -0,0 +1,10 @@ +from .infinite import ( + TecanInfinite200Pro, + TecanInfiniteAbsorbanceBackend, + TecanInfiniteAbsorbanceParams, + TecanInfiniteDriver, + TecanInfiniteFluorescenceBackend, + TecanInfiniteFluorescenceParams, + TecanInfiniteLuminescenceBackend, + TecanInfiniteLuminescenceParams, +) diff --git a/pylabrobot/tecan/infinite/__init__.py b/pylabrobot/tecan/infinite/__init__.py new file mode 100644 index 00000000000..77a389d0b80 --- /dev/null +++ b/pylabrobot/tecan/infinite/__init__.py @@ -0,0 +1,5 @@ +from .absorbance_backend import TecanInfiniteAbsorbanceBackend, TecanInfiniteAbsorbanceParams +from .driver import TecanInfiniteDriver +from .fluorescence_backend import TecanInfiniteFluorescenceBackend, TecanInfiniteFluorescenceParams +from .infinite import TecanInfinite200Pro +from .luminescence_backend import TecanInfiniteLuminescenceBackend, TecanInfiniteLuminescenceParams diff --git a/pylabrobot/tecan/infinite/absorbance_backend.py b/pylabrobot/tecan/infinite/absorbance_backend.py new file mode 100644 index 00000000000..982d2f99d15 --- /dev/null +++ b/pylabrobot/tecan/infinite/absorbance_backend.py @@ -0,0 +1,132 @@ +"""Tecan Infinite 200 PRO absorbance backend.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .driver import TecanInfiniteDriver +from .protocol import _AbsorbanceRunDecoder, _absorbance_od_calibrated, format_plate_result + +logger = logging.getLogger(__name__) + + +@dataclass +class TecanInfiniteAbsorbanceParams(BackendParams): + """Tecan Infinite-specific parameters for absorbance reads. + + Args: + flashes: Number of flashes (reads) per well. Default 25. + bandwidth: Excitation bandwidth in nm. If None, auto-selected + (9 nm for >315 nm, 5 nm otherwise). + """ + + flashes: int = 25 + bandwidth: Optional[float] = None + + +class TecanInfiniteAbsorbanceBackend(AbsorbanceBackend): + """Translates AbsorbanceBackend interface into Tecan Infinite driver commands.""" + + def __init__(self, driver: TecanInfiniteDriver): + self.driver = driver + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + if not isinstance(backend_params, TecanInfiniteAbsorbanceParams): + backend_params = TecanInfiniteAbsorbanceParams() + + if not 230 <= wavelength <= 1_000: + raise ValueError("Absorbance wavelength must be between 230 nm and 1000 nm.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self.driver.scan_visit_order(ordered_wells, serpentine=True) + decoder = _AbsorbanceRunDecoder(len(scan_wells)) + + await self.driver.begin_run() + try: + await self._configure_absorbance( + wavelength, flashes=backend_params.flashes, bandwidth=backend_params.bandwidth + ) + await self.driver.run_scan( + ordered_wells=ordered_wells, + decoder=decoder, + mode="Absorbance", + step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"], + serpentine=True, + scan_direction="ALTUP", + ) + + self.driver.drain_pending_bin_events(decoder) + if len(decoder.measurements) != len(scan_wells): + raise RuntimeError("Absorbance decoder did not complete scan.") + intensities: List[float] = [] + cal = decoder.calibration + if cal is None: + raise RuntimeError("ABS calibration packet not seen; cannot compute calibrated OD.") + for meas in decoder.measurements: + items = meas.items or [(meas.sample, meas.reference)] + od = _absorbance_od_calibrated(cal, items) + intensities.append(od) + matrix = format_plate_result(plate, scan_wells, intensities) + return [ + AbsorbanceResult( + data=matrix, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) + ] + finally: + await self.driver.end_run() + + async def _configure_absorbance( + self, + wavelength_nm: int, + *, + flashes: int, + bandwidth: Optional[float] = None, + ) -> None: + wl_decitenth = int(round(wavelength_nm * 10)) + bw = bandwidth if bandwidth is not None else self._auto_bandwidth(wavelength_nm) + bw_decitenth = int(round(bw * 10)) + reads_number = max(1, flashes) + + await self.driver.send_command("MODE ABS") + await self.driver.clear_mode_settings(excitation=True) + await self.driver.send_command( + f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True + ) + await self.driver.send_command( + f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True + ) + await self.driver.send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self.driver.send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self.driver.send_command("TIME 0,READDELAY=0", allow_timeout=True) + await self.driver.send_command("TIME 1,READDELAY=0", allow_timeout=True) + await self.driver.send_command("SCAN DIRECTION=ALTUP", allow_timeout=True) + await self.driver.send_command("#RATIO LABELS", allow_timeout=True) + await self.driver.send_command( + f"BEAM DIAMETER={self.driver.capability_numeric('ABS', '#BEAM DIAMETER', 700)}", + allow_timeout=True, + ) + await self.driver.send_command("RATIO LABELS=1", allow_timeout=True) + await self.driver.send_command("PREPARE REF", allow_timeout=True, read_response=False) + + @staticmethod + def _auto_bandwidth(wavelength_nm: int) -> float: + return 9.0 if wavelength_nm > 315 else 5.0 diff --git a/pylabrobot/tecan/infinite/driver.py b/pylabrobot/tecan/infinite/driver.py new file mode 100644 index 00000000000..1562dacbe79 --- /dev/null +++ b/pylabrobot/tecan/infinite/driver.py @@ -0,0 +1,425 @@ +"""Tecan Infinite 200 PRO driver. + +Owns the USB connection, connection lifecycle, device-level operations +(initialize, tray control, keylock), shared scan orchestration, and +well-to-stage geometry. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Dict, List, Optional, Sequence, Tuple + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +from pylabrobot.io.usb import USB +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .protocol import ( + _MeasurementDecoder, + _StreamParser, + StagePosition, + frame_command, + is_terminal_frame, +) + +logger = logging.getLogger(__name__) + + +class TecanInfiniteDriver(Driver): + """USB driver for the Tecan Infinite 200 PRO plate reader. + + Owns the USB connection, low-level command protocol, device-level operations + (tray open/close, initialization), shared scan orchestration, and well-to-stage + geometry. + """ + + VENDOR_ID = 0x0C47 + PRODUCT_ID = 0x8007 + + _MODE_CAPABILITY_COMMANDS: Dict[str, List[str]] = { + "ABS": ["#BEAM DIAMETER"], + "FI.TOP": [], + "FI.BOTTOM": [], + "LUM": [], + } + + def __init__( + self, + counts_per_mm_x: float = 1_000, + counts_per_mm_y: float = 1_000, + counts_per_mm_z: float = 1_000, + io: Optional[USB] = None, + ) -> None: + """ + Args: + counts_per_mm_x: Stage counts per mm in X. + counts_per_mm_y: Stage counts per mm in Y. + counts_per_mm_z: Stage counts per mm in Z. + io: Optional USB I/O instance (for test injection). + """ + super().__init__() + self.io = io or USB( + id_vendor=self.VENDOR_ID, + id_product=self.PRODUCT_ID, + human_readable_device_name="Tecan Infinite 200 PRO", + packet_read_timeout=3, + read_timeout=30, + ) + self.counts_per_mm_x = counts_per_mm_x + self.counts_per_mm_y = counts_per_mm_y + self.counts_per_mm_z = counts_per_mm_z + self._setup_lock = asyncio.Lock() + self._ready = False + self._read_chunk_size = 512 + self._max_row_wait_s = 300.0 + self._mode_capabilities: Dict[str, Dict[str, str]] = {} + self._pending_bin_events: List[Tuple[int, bytes]] = [] + self._parser = _StreamParser(allow_bare_ascii=True) + self._run_active = False + self._active_step_loss_commands: List[str] = [] + + # -- lifecycle -- + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + async with self._setup_lock: + if self._ready: + return + await self.io.setup() + await self._initialize_device() + for mode in self._MODE_CAPABILITY_COMMANDS: + if mode not in self._mode_capabilities: + await self._query_mode_capabilities(mode) + self._ready = True + + async def stop(self) -> None: + async with self._setup_lock: + if not self._ready: + return + await self._cleanup_protocol() + await self.io.stop() + self._mode_capabilities.clear() + self._reset_stream_state() + self._ready = False + + # -- device-level operations -- + + async def open_tray(self) -> None: + """Open the reader drawer.""" + await self.send_command("ABSOLUTE MTP,OUT") + await self.send_command("BY#T5000") + + async def close_tray(self) -> None: + """Close the reader drawer.""" + await self.send_command("ABSOLUTE MTP,IN") + await self.send_command("BY#T5000") + + # -- generic I/O -- + + async def send_command( + self, + command: str, + wait_for_terminal: bool = True, + allow_timeout: bool = False, + read_response: bool = True, + ) -> List[str]: + """Send a framed ASCII command and read response frames.""" + logger.debug("[tecan] >> %s", command) + framed = frame_command(command) + await self.io.write(framed) + if not read_response: + return [] + if command.startswith(("#", "?")): + try: + return await self._read_command_response(require_terminal=False) + except TimeoutError: + if allow_timeout: + logger.warning("Timeout waiting for response to %s", command) + return [] + raise + try: + frames = await self._read_command_response(require_terminal=wait_for_terminal) + except TimeoutError: + if allow_timeout: + logger.warning("Timeout waiting for response to %s", command) + return [] + raise + for pkt in frames: + logger.debug("[tecan] << %s", pkt) + return frames + + async def read_packet(self, size: int) -> bytes: + """Read raw bytes from the USB transport.""" + try: + data = await self.io.read(size=size) + except TimeoutError: + await self._recover_transport() + raise + return data + + # -- scan orchestration -- + + async def begin_run(self) -> None: + """Begin a measurement run (KEYLOCK ON, reset stream state).""" + self._reset_stream_state() + await self.send_command("KEYLOCK ON") + self._run_active = True + + async def end_run(self) -> None: + """End a measurement run (TERMINATE, step loss checks, KEYLOCK OFF, MTP IN).""" + try: + await self.send_command("TERMINATE", allow_timeout=True) + for cmd in self._active_step_loss_commands: + await self.send_command(cmd, allow_timeout=True) + await self.send_command("KEYLOCK OFF", allow_timeout=True) + await self.send_command("ABSOLUTE MTP,IN", allow_timeout=True) + finally: + self._run_active = False + self._active_step_loss_commands = [] + + async def run_scan( + self, + ordered_wells: Sequence[Well], + decoder: _MeasurementDecoder, + mode: str, + step_loss_commands: List[str], + serpentine: bool, + scan_direction: str, + ) -> None: + """Run the common scan loop for all measurement types. + + Args: + ordered_wells: The wells to scan in row-major order. + decoder: The decoder to use for parsing measurements. + mode: The mode name for logging (e.g., "Absorbance"). + step_loss_commands: Commands to run after the scan to check for step loss. + serpentine: Whether to use serpentine scan order. + scan_direction: The scan direction command (e.g., "ALTUP", "UP"). + """ + self._active_step_loss_commands = step_loss_commands + + for row_index, row_wells in self.group_by_row(ordered_wells): + start_x, end_x, count = self.scan_range(row_index, row_wells, serpentine=serpentine) + _, y_stage = self.map_well_to_stage(row_wells[0]) + + await self.send_command(f"ABSOLUTE MTP,Y={y_stage}") + await self.send_command(f"ABSOLUTE MTP,X={start_x},Y={y_stage}") + await self.send_command(f"SCAN DIRECTION={scan_direction}") + await self.send_command( + f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False + ) + logger.info( + "Queued %s scan row %s (%s wells): y=%s, x=%s..%s", + mode.lower(), + row_index, + count, + y_stage, + start_x, + end_x, + ) + await self._await_measurements(decoder, count, mode) + await self._await_scan_terminal(decoder.pop_terminal()) + + # -- mode capability queries -- + + async def _query_mode_capabilities(self, mode: str) -> None: + commands = self._MODE_CAPABILITY_COMMANDS.get(mode) + if not commands: + return + try: + await self.send_command(f"MODE {mode}") + except TimeoutError: + logger.warning("Capability MODE %s timed out; continuing without mode capabilities.", mode) + return + collected: Dict[str, str] = {} + for cmd in commands: + try: + frames = await self.send_command(cmd) + except TimeoutError: + logger.warning("Capability query '%s' timed out; proceeding with defaults.", cmd) + continue + if frames: + collected[cmd] = frames[-1] + if collected: + self._mode_capabilities[mode] = collected + + def get_mode_capability(self, mode: str, command: str) -> Optional[str]: + return self._mode_capabilities.get(mode, {}).get(command) + + def capability_numeric(self, mode: str, command: str, fallback: int) -> int: + resp = self.get_mode_capability(mode, command) + if not resp: + return fallback + token = resp.split("|")[0].split(":")[0].split("~")[0].strip() + if not token: + return fallback + try: + return int(float(token)) + except ValueError: + return fallback + + # -- mode settings -- + + async def clear_mode_settings(self, excitation: bool = False, emission: bool = False) -> None: + """Clear mode settings before configuring a new scan.""" + if excitation: + await self.send_command("EXCITATION CLEAR", allow_timeout=True) + if emission: + await self.send_command("EMISSION CLEAR", allow_timeout=True) + await self.send_command("TIME CLEAR", allow_timeout=True) + await self.send_command("GAIN CLEAR", allow_timeout=True) + await self.send_command("READS CLEAR", allow_timeout=True) + await self.send_command("POSITION CLEAR", allow_timeout=True) + await self.send_command("MIRROR CLEAR", allow_timeout=True) + + # -- geometry -- + + def map_well_to_stage(self, well: Well) -> StagePosition: + if well.location is None: + raise ValueError("Well does not have a location assigned within its plate definition.") + center = well.location + well.get_anchor(x="c", y="c") + stage_x = int(round(center.x * self.counts_per_mm_x)) + parent_plate = well.parent + if parent_plate is None or not isinstance(parent_plate, Plate): + raise ValueError("Well is not assigned to a plate; cannot derive stage coordinates.") + plate_height_mm = parent_plate.get_size_y() + stage_y = int(round((plate_height_mm - center.y) * self.counts_per_mm_y)) + return stage_x, stage_y + + def group_by_row(self, wells: Sequence[Well]) -> List[Tuple[int, List[Well]]]: + grouped: Dict[int, List[Well]] = {} + for well in wells: + grouped.setdefault(well.get_row(), []).append(well) + for row in grouped.values(): + row.sort(key=lambda w: w.get_column()) + return sorted(grouped.items(), key=lambda item: item[0]) + + def scan_visit_order(self, wells: Sequence[Well], serpentine: bool) -> List[Well]: + visit: List[Well] = [] + for row_index, row_wells in self.group_by_row(wells): + if serpentine and row_index % 2 == 1: + visit.extend(reversed(row_wells)) + else: + visit.extend(row_wells) + return visit + + def scan_range( + self, row_index: int, row_wells: Sequence[Well], serpentine: bool + ) -> Tuple[int, int, int]: + first_x, _ = self.map_well_to_stage(row_wells[0]) + last_x, _ = self.map_well_to_stage(row_wells[-1]) + count = len(row_wells) + if not serpentine: + return min(first_x, last_x), max(first_x, last_x), count + if row_index % 2 == 0: + return first_x, last_x, count + return last_x, first_x, count + + # -- internal helpers -- + + async def _initialize_device(self) -> None: + try: + await self.send_command("QQ") + except TimeoutError: + logger.warning("QQ produced no response; continuing with initialization.") + await self.send_command("INIT FORCE") + + async def _cleanup_protocol(self) -> None: + async def send_cleanup_cmd(cmd: str) -> None: + try: + await self.send_command(cmd, allow_timeout=True, read_response=False) + except Exception: + logger.warning("Cleanup command failed: %s", cmd) + + if self._run_active or self._active_step_loss_commands: + await send_cleanup_cmd("TERMINATE") + for cmd in self._active_step_loss_commands: + await send_cleanup_cmd(cmd) + await send_cleanup_cmd("KEYLOCK OFF") + await send_cleanup_cmd("ABSOLUTE MTP,IN") + self._run_active = False + self._active_step_loss_commands = [] + + async def _await_measurements( + self, decoder: _MeasurementDecoder, row_count: int, mode: str + ) -> None: + target = decoder.count + row_count + start_count = decoder.count + self.drain_pending_bin_events(decoder) + start = time.monotonic() + reads = 0 + while decoder.count < target and (time.monotonic() - start) < self._max_row_wait_s: + chunk = await self.read_packet(self._read_chunk_size) + if not chunk: + raise RuntimeError(f"{mode} read returned empty chunk; transport may not support reads.") + decoder.feed(chunk) + reads += 1 + if decoder.count < target: + got = decoder.count - start_count + raise RuntimeError( + f"Timed out while parsing {mode.lower()} results " + f"(decoded {got}/{row_count} measurements in {time.monotonic() - start:.1f}s, {reads} reads)." + ) + + def drain_pending_bin_events(self, decoder: _MeasurementDecoder) -> None: + if not self._pending_bin_events: + return + for payload_len, blob in self._pending_bin_events: + decoder.feed_bin(payload_len, blob) + self._pending_bin_events.clear() + + async def _await_scan_terminal(self, saw_terminal: bool) -> None: + if saw_terminal: + return + await self._read_command_response() + + def _reset_stream_state(self) -> None: + self._pending_bin_events.clear() + self._parser = _StreamParser(allow_bare_ascii=True) + + async def _read_command_response( + self, max_iterations: int = 8, require_terminal: bool = True + ) -> List[str]: + """Read response frames and cache any binary payloads that arrive.""" + frames: List[str] = [] + saw_terminal = False + for _ in range(max_iterations): + chunk = await self.read_packet(128) + if not chunk: + break + for event in self._parser.feed(chunk): + if event.text is not None: + frames.append(event.text) + if is_terminal_frame(event.text): + saw_terminal = True + elif event.payload_len is not None and event.blob is not None: + self._pending_bin_events.append((event.payload_len, event.blob)) + if not require_terminal and frames and not self._parser.has_pending_bin(): + break + if require_terminal and saw_terminal and not self._parser.has_pending_bin(): + break + if require_terminal and not saw_terminal: + await self._drain(1) + return frames + + async def _recover_transport(self) -> None: + try: + await self.io.stop() + await asyncio.sleep(0.2) + await self.io.setup() + except Exception: + logger.warning("Transport recovery failed.", exc_info=True) + return + self._mode_capabilities.clear() + self._reset_stream_state() + await self._initialize_device() + + async def _drain(self, attempts: int = 4) -> None: + """Read and discard a few packets to clear the stream.""" + for _ in range(attempts): + data = await self.read_packet(128) + if not data: + break diff --git a/pylabrobot/tecan/infinite/fluorescence_backend.py b/pylabrobot/tecan/infinite/fluorescence_backend.py new file mode 100644 index 00000000000..b3144886380 --- /dev/null +++ b/pylabrobot/tecan/infinite/fluorescence_backend.py @@ -0,0 +1,166 @@ +"""Tecan Infinite 200 PRO fluorescence backend.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .driver import TecanInfiniteDriver +from .protocol import _FluorescenceRunDecoder, format_plate_result + +logger = logging.getLogger(__name__) + + +@dataclass +class TecanInfiniteFluorescenceParams(BackendParams): + """Tecan Infinite-specific parameters for fluorescence reads. + + Args: + flashes: Number of flashes (reads) per well. Default 25. + integration_us: Integration time in microseconds. Default 20. + gain: PMT gain value (0-255). Default 100. + excitation_bandwidth: Excitation filter bandwidth in deci-tenths of nm. Default 50. + emission_bandwidth: Emission filter bandwidth in deci-tenths of nm. Default 200. + lag_us: Lag time in microseconds between excitation and measurement. Default 0. + """ + + flashes: int = 25 + integration_us: int = 20 + gain: int = 100 + excitation_bandwidth: int = 50 + emission_bandwidth: int = 200 + lag_us: int = 0 + + +class TecanInfiniteFluorescenceBackend(FluorescenceBackend): + """Translates FluorescenceBackend interface into Tecan Infinite driver commands.""" + + def __init__(self, driver: TecanInfiniteDriver): + self.driver = driver + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + if not isinstance(backend_params, TecanInfiniteFluorescenceParams): + backend_params = TecanInfiniteFluorescenceParams() + + if not 230 <= excitation_wavelength <= 850: + raise ValueError("Excitation wavelength must be between 230 nm and 850 nm.") + if not 230 <= emission_wavelength <= 850: + raise ValueError("Emission wavelength must be between 230 nm and 850 nm.") + if focal_height < 0: + raise ValueError("Focal height must be non-negative for fluorescence scans.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self.driver.scan_visit_order(ordered_wells, serpentine=True) + + await self.driver.begin_run() + try: + await self._configure_fluorescence( + excitation_wavelength, + emission_wavelength, + focal_height, + flashes=backend_params.flashes, + integration_us=backend_params.integration_us, + gain=backend_params.gain, + excitation_bandwidth=backend_params.excitation_bandwidth, + emission_bandwidth=backend_params.emission_bandwidth, + lag_us=backend_params.lag_us, + ) + decoder = _FluorescenceRunDecoder(len(scan_wells)) + + await self.driver.run_scan( + ordered_wells=ordered_wells, + decoder=decoder, + mode="Fluorescence", + step_loss_commands=[ + "CHECK MTP.STEPLOSS", + "CHECK FI.TOP.STEPLOSS", + "CHECK FI.STEPLOSS.Z", + ], + serpentine=True, + scan_direction="UP", + ) + + if len(decoder.intensities) != len(scan_wells): + raise RuntimeError("Fluorescence decoder did not complete scan.") + intensities = decoder.intensities + matrix = format_plate_result(plate, scan_wells, intensities) + return [ + FluorescenceResult( + data=matrix, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + temperature=None, + timestamp=time.time(), + ) + ] + finally: + await self.driver.end_run() + + async def _configure_fluorescence( + self, + excitation_nm: int, + emission_nm: int, + focal_height: float, + *, + flashes: int, + integration_us: int, + gain: int, + excitation_bandwidth: int, + emission_bandwidth: int, + lag_us: int, + ) -> None: + ex_decitenth = int(round(excitation_nm * 10)) + em_decitenth = int(round(emission_nm * 10)) + reads_number = max(1, flashes) + beam_diameter = self.driver.capability_numeric("FI.TOP", "#BEAM DIAMETER", 3000) + z_position = int(round(focal_height * self.driver.counts_per_mm_z)) + + # UI issues the entire FI configuration twice before PREPARE REF. + for _ in range(2): + await self.driver.send_command("MODE FI.TOP", allow_timeout=True) + await self.driver.clear_mode_settings(excitation=True, emission=True) + await self.driver.send_command( + f"EXCITATION 0,FI,{ex_decitenth},{excitation_bandwidth},0", allow_timeout=True + ) + await self.driver.send_command( + f"EMISSION 0,FI,{em_decitenth},{emission_bandwidth},0", allow_timeout=True + ) + await self.driver.send_command(f"TIME 0,INTEGRATION={integration_us}", allow_timeout=True) + await self.driver.send_command(f"TIME 0,LAG={lag_us}", allow_timeout=True) + await self.driver.send_command("TIME 0,READDELAY=0", allow_timeout=True) + await self.driver.send_command(f"GAIN 0,VALUE={gain}", allow_timeout=True) + await self.driver.send_command(f"POSITION 0,Z={z_position}", allow_timeout=True) + await self.driver.send_command(f"BEAM DIAMETER={beam_diameter}", allow_timeout=True) + await self.driver.send_command("SCAN DIRECTION=UP", allow_timeout=True) + await self.driver.send_command("RATIO LABELS=1", allow_timeout=True) + await self.driver.send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self.driver.send_command( + f"EXCITATION 1,FI,{ex_decitenth},{excitation_bandwidth},0", allow_timeout=True + ) + await self.driver.send_command( + f"EMISSION 1,FI,{em_decitenth},{emission_bandwidth},0", allow_timeout=True + ) + await self.driver.send_command(f"TIME 1,INTEGRATION={integration_us}", allow_timeout=True) + await self.driver.send_command(f"TIME 1,LAG={lag_us}", allow_timeout=True) + await self.driver.send_command("TIME 1,READDELAY=0", allow_timeout=True) + await self.driver.send_command(f"GAIN 1,VALUE={gain}", allow_timeout=True) + await self.driver.send_command(f"POSITION 1,Z={z_position}", allow_timeout=True) + await self.driver.send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self.driver.send_command("PREPARE REF", allow_timeout=True, read_response=False) diff --git a/pylabrobot/tecan/infinite/infinite.py b/pylabrobot/tecan/infinite/infinite.py new file mode 100644 index 00000000000..04d2837de31 --- /dev/null +++ b/pylabrobot/tecan/infinite/infinite.py @@ -0,0 +1,78 @@ +"""Tecan Infinite 200 PRO plate reader device.""" + +from __future__ import annotations + +from pylabrobot.capabilities.loading_tray import HasLoadingTray, LoadingTray +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Resource + +from .absorbance_backend import TecanInfiniteAbsorbanceBackend +from .driver import TecanInfiniteDriver +from .fluorescence_backend import TecanInfiniteFluorescenceBackend +from .loading_tray_backend import TecanInfiniteLoadingTrayBackend +from .luminescence_backend import TecanInfiniteLuminescenceBackend + + +class TecanInfinite200Pro(Resource, Device, HasLoadingTray): + """Tecan Infinite 200 PRO plate reader. + + Supports absorbance, fluorescence, and luminescence measurements. + + Examples: + >>> reader = TecanInfinite200Pro(name="infinite") + >>> await reader.setup() + >>> results = await reader.absorbance.read(plate=my_plate, wavelength=600) + >>> await reader.stop() + """ + + def __init__( + self, + name: str, + counts_per_mm_x: float = 1_000, + counts_per_mm_y: float = 1_000, + counts_per_mm_z: float = 1_000, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + driver = TecanInfiniteDriver( + counts_per_mm_x=counts_per_mm_x, + counts_per_mm_y=counts_per_mm_y, + counts_per_mm_z=counts_per_mm_z, + ) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Tecan Infinite 200 PRO", + category="plate_reader", + ) + Device.__init__(self, driver=driver) + self.driver: TecanInfiniteDriver = driver + + self.absorbance = Absorbance(backend=TecanInfiniteAbsorbanceBackend(driver)) + self.fluorescence = Fluorescence(backend=TecanInfiniteFluorescenceBackend(driver)) + self.luminescence = Luminescence(backend=TecanInfiniteLuminescenceBackend(driver)) + self.loading_tray = LoadingTray( + backend=TecanInfiniteLoadingTrayBackend(driver), + name=name + "_loading_tray", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + self._capabilities = [ + self.absorbance, + self.fluorescence, + self.luminescence, + self.loading_tray, + ] + self.assign_child_resource(self.loading_tray, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/tecan/infinite/infinite_tests.py b/pylabrobot/tecan/infinite/infinite_tests.py new file mode 100644 index 00000000000..39003c9eb14 --- /dev/null +++ b/pylabrobot/tecan/infinite/infinite_tests.py @@ -0,0 +1,281 @@ +"""Tests for the new Tecan Infinite 200 PRO architecture.""" + +import unittest +from unittest.mock import AsyncMock, patch + +from pylabrobot.io.usb import USB +from pylabrobot.resources import Coordinate, Plate, Well, create_ordered_items_2d +from pylabrobot.tecan.infinite.driver import TecanInfiniteDriver +from pylabrobot.tecan.infinite.protocol import ( + _AbsorbanceRunDecoder, + _FluorescenceRunDecoder, + _LuminescenceRunDecoder, + _absorbance_od_calibrated, + _consume_leading_ascii_frame, + frame_command, + is_terminal_frame, +) + + +def _pack_u16(words): + return b"".join(int(word).to_bytes(2, "big") for word in words) + + +def _bin_blob(payload): + payload_len = len(payload) + trailer = b"\x00\x00\x00\x00" + return payload_len, payload + trailer + + +def _abs_calibration_blob(ex_decitenth, meas_dark, meas_bright, ref_dark, ref_bright): + header = _pack_u16([0, ex_decitenth]) + item = (0).to_bytes(4, "big") + _pack_u16([0, 0, meas_dark, meas_bright, 0, ref_dark, ref_bright]) + return _bin_blob(header + item) + + +def _abs_data_blob(ex_decitenth, meas, ref): + payload = _pack_u16([0, ex_decitenth, 0, 0, 0, meas, ref]) + return _bin_blob(payload) + + +def _flr_calibration_blob(ex_decitenth, meas_dark, ref_dark, ref_bright): + words = [ex_decitenth, 0, 0, 0, 0, meas_dark, 0, ref_dark, ref_bright] + return _bin_blob(_pack_u16(words)) + + +def _flr_data_blob(ex_decitenth, em_decitenth, meas, ref): + words = [0, ex_decitenth, em_decitenth, 0, 0, 0, meas, ref] + return _bin_blob(_pack_u16(words)) + + +def _lum_data_blob(em_decitenth: int, intensity: int): + payload = bytearray(14) + payload[0:2] = (0).to_bytes(2, "big") + payload[2:4] = int(em_decitenth).to_bytes(2, "big") + payload[10:14] = int(intensity).to_bytes(4, "big", signed=True) + return _bin_blob(bytes(payload)) + + +def _make_test_plate(): + plate = Plate( + "plate", + size_x=30, + size_y=20, + size_z=10, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=3, + num_items_y=2, + dx=1, + dy=2, + dz=0, + item_dx=10, + item_dy=8, + size_x=4, + size_y=4, + size_z=5, + ), + ) + plate.location = Coordinate.zero() + return plate + + +# --------------------------------------------------------------------------- +# Protocol tests +# --------------------------------------------------------------------------- + + +class TestProtocol(unittest.TestCase): + def test_frame_command(self): + framed = frame_command("A") + self.assertEqual(framed, b"\x02A\x03\x00\x00\x01\x40\x0d") + + def test_consume_leading_ascii_frame(self): + buffer = bytearray(frame_command("ST") + b"XYZ") + consumed, text = _consume_leading_ascii_frame(buffer) + self.assertTrue(consumed) + self.assertEqual(text, "ST") + self.assertEqual(buffer, bytearray(b"XYZ")) + + def test_terminal_frames(self): + self.assertTrue(is_terminal_frame("ST")) + self.assertTrue(is_terminal_frame("+")) + self.assertTrue(is_terminal_frame("-")) + self.assertTrue(is_terminal_frame("BY#T5000")) + self.assertFalse(is_terminal_frame("OK")) + + +class TestDecoders(unittest.TestCase): + def test_absorbance_decoder(self): + decoder = _AbsorbanceRunDecoder(1) + cal_len, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) + decoder.feed_bin(cal_len, cal_blob) + self.assertIsNotNone(decoder.calibration) + data_len, data_blob = _abs_data_blob(6000, 500, 1000) + decoder.feed_bin(data_len, data_blob) + self.assertTrue(decoder.done) + od = _absorbance_od_calibrated(decoder.calibration, [(500, 1000)]) + self.assertAlmostEqual(od, 0.3010299956639812) + + def test_fluorescence_decoder(self): + decoder = _FluorescenceRunDecoder(1) + cal_len, cal_blob = _flr_calibration_blob(4850, 0, 0, 1000) + decoder.feed_bin(cal_len, cal_blob) + data_len, data_blob = _flr_data_blob(4850, 5200, 500, 1000) + decoder.feed_bin(data_len, data_blob) + self.assertTrue(decoder.done) + self.assertEqual(decoder.intensities[0], 500) + + def test_luminescence_decoder(self): + decoder = _LuminescenceRunDecoder(1) + data_len, data_blob = _lum_data_blob(0, 42) + decoder.feed_bin(data_len, data_blob) + self.assertTrue(decoder.done) + self.assertEqual(decoder.measurements[0].intensity, 42) + + +# --------------------------------------------------------------------------- +# Driver geometry tests +# --------------------------------------------------------------------------- + + +class TestDriverGeometry(unittest.TestCase): + def setUp(self): + self.driver = TecanInfiniteDriver(counts_per_mm_x=1, counts_per_mm_y=1, counts_per_mm_z=1) + self.plate = _make_test_plate() + + def test_scan_visit_order_serpentine(self): + order = self.driver.scan_visit_order(self.plate.get_all_items(), serpentine=True) + identifiers = [well.get_identifier() for well in order] + self.assertEqual(identifiers, ["A1", "A2", "A3", "B3", "B2", "B1"]) + + def test_scan_visit_order_linear(self): + order = self.driver.scan_visit_order(self.plate.get_all_items(), serpentine=False) + identifiers = [well.get_identifier() for well in order] + self.assertEqual(identifiers, ["A1", "A2", "A3", "B1", "B2", "B3"]) + + def test_map_well_to_stage(self): + stage_x, stage_y = self.driver.map_well_to_stage(self.plate.get_well("A1")) + self.assertEqual((stage_x, stage_y), (3, 8)) + stage_x, stage_y = self.driver.map_well_to_stage(self.plate.get_well("B1")) + self.assertEqual((stage_x, stage_y), (3, 16)) + + +# --------------------------------------------------------------------------- +# Backend integration tests +# --------------------------------------------------------------------------- + + +class TestAbsorbanceBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_usb = AsyncMock(spec=USB) + self.mock_usb.setup = AsyncMock() + self.mock_usb.stop = AsyncMock() + self.mock_usb.write = AsyncMock() + self.mock_usb.read = AsyncMock(return_value=frame_command("ST")) + self.plate = _make_test_plate() + + async def test_read_absorbance(self): + from pylabrobot.tecan.infinite.absorbance_backend import ( + TecanInfiniteAbsorbanceBackend, + TecanInfiniteAbsorbanceParams, + ) + + driver = TecanInfiniteDriver(counts_per_mm_x=1000, counts_per_mm_y=1000, io=self.mock_usb) + driver._ready = True + backend = TecanInfiniteAbsorbanceBackend(driver) + + async def mock_await(decoder, row_count, mode): + cal_len, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) + decoder.feed_bin(cal_len, cal_blob) + for _ in range(row_count): + data_len, data_blob = _abs_data_blob(6000, 500, 1000) + decoder.feed_bin(data_len, data_blob) + + with patch.object(driver, "_await_measurements", side_effect=mock_await): + with patch.object(driver, "_await_scan_terminal", new_callable=AsyncMock): + results = await backend.read_absorbance( + plate=self.plate, wells=[], wavelength=600, + backend_params=TecanInfiniteAbsorbanceParams(), + ) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].wavelength, 600) + self.assertIsNotNone(results[0].data) + self.assertAlmostEqual(results[0].data[0][0], 0.3010299956639812) + + +class TestFluorescenceBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_usb = AsyncMock(spec=USB) + self.mock_usb.setup = AsyncMock() + self.mock_usb.stop = AsyncMock() + self.mock_usb.write = AsyncMock() + self.mock_usb.read = AsyncMock(return_value=frame_command("ST")) + self.plate = _make_test_plate() + + async def test_read_fluorescence(self): + from pylabrobot.tecan.infinite.fluorescence_backend import ( + TecanInfiniteFluorescenceBackend, + TecanInfiniteFluorescenceParams, + ) + + driver = TecanInfiniteDriver(counts_per_mm_x=1000, counts_per_mm_y=1000, io=self.mock_usb) + driver._ready = True + backend = TecanInfiniteFluorescenceBackend(driver) + + async def mock_await(decoder, row_count, mode): + cal_len, cal_blob = _flr_calibration_blob(4850, 0, 0, 1000) + decoder.feed_bin(cal_len, cal_blob) + for _ in range(row_count): + data_len, data_blob = _flr_data_blob(4850, 5200, 500, 1000) + decoder.feed_bin(data_len, data_blob) + + with patch.object(driver, "_await_measurements", side_effect=mock_await): + with patch.object(driver, "_await_scan_terminal", new_callable=AsyncMock): + results = await backend.read_fluorescence( + plate=self.plate, wells=[], excitation_wavelength=485, + emission_wavelength=520, focal_height=20.0, + backend_params=TecanInfiniteFluorescenceParams(), + ) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 520) + + +class TestLuminescenceBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_usb = AsyncMock(spec=USB) + self.mock_usb.setup = AsyncMock() + self.mock_usb.stop = AsyncMock() + self.mock_usb.write = AsyncMock() + self.mock_usb.read = AsyncMock(return_value=frame_command("ST")) + self.plate = _make_test_plate() + + async def test_read_luminescence(self): + from pylabrobot.tecan.infinite.luminescence_backend import ( + TecanInfiniteLuminescenceBackend, + TecanInfiniteLuminescenceParams, + ) + + driver = TecanInfiniteDriver(counts_per_mm_x=1000, counts_per_mm_y=1000, io=self.mock_usb) + driver._ready = True + backend = TecanInfiniteLuminescenceBackend(driver) + + async def mock_await(decoder, row_count, mode): + cal_blob = bytes(14) + decoder.feed_bin(10, cal_blob) + for _ in range(row_count): + data_len, data_blob = _lum_data_blob(0, 1000) + decoder.feed_bin(data_len, data_blob) + + with patch.object(driver, "_await_measurements", side_effect=mock_await): + with patch.object(driver, "_await_scan_terminal", new_callable=AsyncMock): + results = await backend.read_luminescence( + plate=self.plate, wells=[], focal_height=14.62, + backend_params=TecanInfiniteLuminescenceParams(), + ) + + self.assertEqual(len(results), 1) + self.assertIsNotNone(results[0].data) diff --git a/pylabrobot/tecan/infinite/loading_tray_backend.py b/pylabrobot/tecan/infinite/loading_tray_backend.py new file mode 100644 index 00000000000..8ceed2c3c07 --- /dev/null +++ b/pylabrobot/tecan/infinite/loading_tray_backend.py @@ -0,0 +1,21 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray.backend import LoadingTrayBackend + +from .driver import TecanInfiniteDriver + + +class TecanInfiniteLoadingTrayBackend(LoadingTrayBackend): + """Loading tray backend for Tecan Infinite plate readers.""" + + def __init__(self, driver: TecanInfiniteDriver): + self._driver = driver + + async def open(self, backend_params: Optional[BackendParams] = None): + await self._driver.send_command("ABSOLUTE MTP,OUT") + await self._driver.send_command("BY#T5000") + + async def close(self, backend_params: Optional[BackendParams] = None): + await self._driver.send_command("ABSOLUTE MTP,IN") + await self._driver.send_command("BY#T5000") diff --git a/pylabrobot/tecan/infinite/luminescence_backend.py b/pylabrobot/tecan/infinite/luminescence_backend.py new file mode 100644 index 00000000000..8a11950f5f7 --- /dev/null +++ b/pylabrobot/tecan/infinite/luminescence_backend.py @@ -0,0 +1,128 @@ +"""Tecan Infinite 200 PRO luminescence backend.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .driver import TecanInfiniteDriver +from .protocol import ( + _LuminescenceRunDecoder, + _integration_microseconds_to_seconds, + format_plate_result, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class TecanInfiniteLuminescenceParams(BackendParams): + """Tecan Infinite-specific parameters for luminescence reads. + + Args: + flashes: Number of flashes (reads) per well. Default 25. + dark_integration_us: Dark integration time in microseconds. Default 3,000,000. + meas_integration_us: Measurement integration time in microseconds. Default 1,000,000. + """ + + flashes: int = 25 + dark_integration_us: int = 3_000_000 + meas_integration_us: int = 1_000_000 + + +class TecanInfiniteLuminescenceBackend(LuminescenceBackend): + """Translates LuminescenceBackend interface into Tecan Infinite driver commands.""" + + def __init__(self, driver: TecanInfiniteDriver): + self.driver = driver + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + if not isinstance(backend_params, TecanInfiniteLuminescenceParams): + backend_params = TecanInfiniteLuminescenceParams() + + if focal_height < 0: + raise ValueError("Focal height must be non-negative for luminescence scans.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self.driver.scan_visit_order(ordered_wells, serpentine=False) + + dark_integration = backend_params.dark_integration_us + meas_integration = backend_params.meas_integration_us + + await self.driver.begin_run() + try: + await self._configure_luminescence( + dark_integration, meas_integration, focal_height, flashes=backend_params.flashes + ) + + decoder = _LuminescenceRunDecoder( + len(scan_wells), + dark_integration_s=_integration_microseconds_to_seconds(dark_integration), + meas_integration_s=_integration_microseconds_to_seconds(meas_integration), + ) + + await self.driver.run_scan( + ordered_wells=ordered_wells, + decoder=decoder, + mode="Luminescence", + step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"], + serpentine=False, + scan_direction="UP", + ) + + if len(decoder.measurements) != len(scan_wells): + raise RuntimeError("Luminescence decoder did not complete scan.") + intensities = [measurement.intensity for measurement in decoder.measurements] + matrix = format_plate_result(plate, scan_wells, intensities) + return [ + LuminescenceResult( + data=matrix, + temperature=None, + timestamp=time.time(), + ) + ] + finally: + await self.driver.end_run() + + async def _configure_luminescence( + self, + dark_integration: int, + meas_integration: int, + focal_height: float, + *, + flashes: int, + ) -> None: + await self.driver.send_command("MODE LUM") + await self.driver.send_command("CHECK LUM.FIBER") + await self.driver.send_command("CHECK LUM.LID") + await self.driver.send_command("CHECK LUM.STEPLOSS") + await self.driver.send_command("MODE LUM") + reads_number = max(1, flashes) + z_position = int(round(focal_height * self.driver.counts_per_mm_z)) + await self.driver.clear_mode_settings(emission=True) + await self.driver.send_command(f"POSITION LUM,Z={z_position}", allow_timeout=True) + await self.driver.send_command(f"TIME 0,INTEGRATION={dark_integration}", allow_timeout=True) + await self.driver.send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self.driver.send_command("SCAN DIRECTION=UP", allow_timeout=True) + await self.driver.send_command("RATIO LABELS=1", allow_timeout=True) + await self.driver.send_command("EMISSION 1,EMPTY,0,0,0", allow_timeout=True) + await self.driver.send_command(f"TIME 1,INTEGRATION={meas_integration}", allow_timeout=True) + await self.driver.send_command("TIME 1,READDELAY=0", allow_timeout=True) + await self.driver.send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self.driver.send_command("#EMISSION ATTENUATION", allow_timeout=True) + await self.driver.send_command("PREPARE REF", allow_timeout=True, read_response=False) diff --git a/pylabrobot/tecan/infinite/protocol.py b/pylabrobot/tecan/infinite/protocol.py new file mode 100644 index 00000000000..00eddd521e7 --- /dev/null +++ b/pylabrobot/tecan/infinite/protocol.py @@ -0,0 +1,612 @@ +"""Tecan Infinite 200 PRO protocol utilities. + +Pure functions for framing, stream parsing, binary decoding, and calibration math. +No I/O -- used by both the driver and capability backends. +""" + +from __future__ import annotations + +import math +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +from pylabrobot.io.binary import Reader +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +BIN_RE = re.compile(r"^(\d+),BIN:$") + +StagePosition = Tuple[int, int] + + +# --------------------------------------------------------------------------- +# Framing +# --------------------------------------------------------------------------- + + +def frame_command(command: str) -> bytes: + """Return a framed command with length/checksum trailer.""" + payload = command.encode("ascii") + xor = 0 + for byte in payload: + xor ^= byte + checksum = (xor ^ 0x01) & 0xFF + length = len(payload) & 0xFF + return b"\x02" + payload + b"\x03\x00\x00" + bytes([length, checksum]) + b"\x0d" + + +def is_terminal_frame(text: str) -> bool: + """Return True if the ASCII frame is a terminal marker.""" + return text in {"ST", "+", "-"} or text.startswith("BY#T") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _integration_microseconds_to_seconds(value: int) -> float: + return value / 1_000_000.0 + + +def format_plate_result( + plate: Plate, wells: Sequence[Well], values: Sequence[float] +) -> List[List[Optional[float]]]: + """Place per-well values into a 2D ``[row][col]`` matrix matching the plate layout.""" + matrix: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for well, val in zip(wells, values): + r, c = well.get_row(), well.get_column() + if 0 <= r < plate.num_items_y and 0 <= c < plate.num_items_x: + matrix[r][c] = float(val) + return matrix + + +def _split_payload_and_trailer( + payload_len: int, blob: bytes +) -> Optional[Tuple[bytes, Tuple[int, int]]]: + if len(blob) != payload_len + 4: + return None + payload = blob[:payload_len] + trailer_reader = Reader(blob[payload_len:], little_endian=False) + return payload, (trailer_reader.u16(), trailer_reader.u16()) + + +# --------------------------------------------------------------------------- +# Stream parsing +# --------------------------------------------------------------------------- + + +def _consume_leading_ascii_frame(buffer: bytearray) -> Tuple[bool, Optional[str]]: + """Remove a leading STX...ETX ASCII frame if present.""" + if not buffer or buffer[0] != 0x02: + return False, None + end = buffer.find(b"\x03", 1) + if end == -1: + return False, None + if len(buffer) < end + 5: + return False, None + text = buffer[1:end].decode("ascii", "ignore") + del buffer[: end + 5] + if buffer and buffer[0] == 0x0D: + del buffer[0] + return True, text + + +def _consume_status_frame(buffer: bytearray, length: int) -> bool: + """Drop a leading ESC-prefixed status frame if present.""" + if len(buffer) >= length and buffer[0] == 0x1B: + del buffer[:length] + return True + return False + + +@dataclass +class _StreamEvent: + """Parsed stream event (ASCII or binary).""" + text: Optional[str] = None + payload_len: Optional[int] = None + blob: Optional[bytes] = None + + +class _StreamParser: + """Parse mixed ASCII and binary packets from the reader.""" + + def __init__( + self, + *, + status_frame_len: Optional[int] = None, + allow_bare_ascii: bool = False, + ) -> None: + self._buffer = bytearray() + self._pending_bin: Optional[int] = None + self._status_frame_len = status_frame_len + self._allow_bare_ascii = allow_bare_ascii + + def has_pending_bin(self) -> bool: + """Return True if a binary payload length is pending.""" + return self._pending_bin is not None + + def feed(self, chunk: bytes) -> List[_StreamEvent]: + """Feed raw bytes and return newly parsed events.""" + self._buffer.extend(chunk) + events: List[_StreamEvent] = [] + progressed = True + while progressed: + progressed = False + if self._pending_bin is not None: + need = self._pending_bin + 4 + if len(self._buffer) < need: + break + blob = bytes(self._buffer[:need]) + del self._buffer[:need] + events.append(_StreamEvent(payload_len=self._pending_bin, blob=blob)) + self._pending_bin = None + progressed = True + continue + if self._status_frame_len and _consume_status_frame(self._buffer, self._status_frame_len): + progressed = True + continue + consumed, text = _consume_leading_ascii_frame(self._buffer) + if consumed: + events.append(_StreamEvent(text=text)) + if text: + m = BIN_RE.match(text) + if m: + self._pending_bin = int(m.group(1)) + progressed = True + continue + if self._allow_bare_ascii and self._buffer and all(32 <= b <= 126 for b in self._buffer): + text = self._buffer.decode("ascii", "ignore") + self._buffer.clear() + events.append(_StreamEvent(text=text)) + progressed = True + continue + return events + + +# --------------------------------------------------------------------------- +# Measurement decoder base +# --------------------------------------------------------------------------- + + +class _MeasurementDecoder(ABC): + """Shared incremental decoder for Infinite measurement streams.""" + + STATUS_FRAME_LEN: Optional[int] = None + + def __init__(self, expected: int) -> None: + self.expected = expected + self._terminal_seen = False + self._parser = _StreamParser(status_frame_len=self.STATUS_FRAME_LEN) + + @property + @abstractmethod + def count(self) -> int: + """Return number of decoded measurements so far.""" + + @property + def done(self) -> bool: + return self.count >= self.expected + + def pop_terminal(self) -> bool: + seen = self._terminal_seen + self._terminal_seen = False + return seen + + def feed(self, chunk: bytes) -> None: + for event in self._parser.feed(chunk): + if event.text is not None: + if event.text == "ST": + self._terminal_seen = True + elif event.payload_len is not None and event.blob is not None: + self.feed_bin(event.payload_len, event.blob) + + def feed_bin(self, payload_len: int, blob: bytes) -> None: + if self._should_consume_bin(payload_len): + self._handle_bin(payload_len, blob) + + def _should_consume_bin(self, _payload_len: int) -> bool: + return False + + def _handle_bin(self, _payload_len: int, _blob: bytes) -> None: + return None + + +# --------------------------------------------------------------------------- +# Absorbance decoding & calibration +# --------------------------------------------------------------------------- + + +def _is_abs_calibration_len(payload_len: int) -> bool: + return payload_len >= 22 and (payload_len - 4) % 18 == 0 + + +def _is_abs_data_len(payload_len: int) -> bool: + return payload_len >= 14 and (payload_len - 4) % 10 == 0 + + +@dataclass(frozen=True) +class _AbsorbanceCalibrationItem: + ticker_overflows: int + ticker_counter: int + meas_gain: int + meas_dark: int + meas_bright: int + ref_gain: int + ref_dark: int + ref_bright: int + + +@dataclass(frozen=True) +class _AbsorbanceCalibration: + ex: int + items: List[_AbsorbanceCalibrationItem] + + +def _decode_abs_calibration(payload_len: int, blob: bytes) -> Optional[_AbsorbanceCalibration]: + split = _split_payload_and_trailer(payload_len, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 4 + 18: + return None + if (len(payload) - 4) % 18 != 0: + return None + reader = Reader(payload, little_endian=False) + reader.raw_bytes(2) + ex = reader.u16() + items: List[_AbsorbanceCalibrationItem] = [] + while reader.has_remaining(): + items.append( + _AbsorbanceCalibrationItem( + ticker_overflows=reader.u32(), + ticker_counter=reader.u16(), + meas_gain=reader.u16(), + meas_dark=reader.u16(), + meas_bright=reader.u16(), + ref_gain=reader.u16(), + ref_dark=reader.u16(), + ref_bright=reader.u16(), + ) + ) + return _AbsorbanceCalibration(ex=ex, items=items) + + +def _decode_abs_data( + payload_len: int, blob: bytes +) -> Optional[Tuple[int, int, List[Tuple[int, int]]]]: + split = _split_payload_and_trailer(payload_len, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 4: + return None + reader = Reader(payload, little_endian=False) + label = reader.u16() + ex = reader.u16() + items: List[Tuple[int, int]] = [] + while reader.offset() + 10 <= len(payload): + reader.raw_bytes(6) + meas = reader.u16() + ref = reader.u16() + items.append((meas, ref)) + if reader.offset() != len(payload): + return None + return label, ex, items + + +def _absorbance_od_calibrated( + cal: _AbsorbanceCalibration, meas_ref_items: List[Tuple[int, int]], od_max: float = 4.0 +) -> float: + if not cal.items: + raise ValueError("ABS calibration packet contained no calibration items.") + + min_corr_trans = math.pow(10.0, -od_max) + + if len(cal.items) == len(meas_ref_items) and len(cal.items) > 1: + corr_trans_vals: List[float] = [] + for (meas, ref), cal_item in zip(meas_ref_items, cal.items): + denom_corr = cal_item.meas_bright - cal_item.meas_dark + if denom_corr == 0: + continue + f_corr = (cal_item.ref_bright - cal_item.ref_dark) / denom_corr + denom = ref - cal_item.ref_dark + if denom == 0: + continue + corr_trans_vals.append(((meas - cal_item.meas_dark) / denom) * f_corr) + if not corr_trans_vals: + raise ZeroDivisionError("ABS invalid: no usable reads after per-read calibration.") + corr_trans = max(sum(corr_trans_vals) / len(corr_trans_vals), min_corr_trans) + return float(-math.log10(corr_trans)) + + cal0 = cal.items[0] + denom_corr = cal0.meas_bright - cal0.meas_dark + if denom_corr == 0: + raise ZeroDivisionError("ABS calibration invalid: meas_bright == meas_dark") + f_corr = (cal0.ref_bright - cal0.ref_dark) / denom_corr + + trans_vals: List[float] = [] + for meas, ref in meas_ref_items: + denom = ref - cal0.ref_dark + if denom == 0: + continue + trans_vals.append((meas - cal0.meas_dark) / denom) + if not trans_vals: + raise ZeroDivisionError("ABS invalid: all ref reads equal ref_dark") + + trans_mean = sum(trans_vals) / len(trans_vals) + corr_trans = max(trans_mean * f_corr, min_corr_trans) + return float(-math.log10(corr_trans)) + + +@dataclass +class _AbsorbanceMeasurement: + sample: int + reference: int + items: Optional[List[Tuple[int, int]]] = None + + +class _AbsorbanceRunDecoder(_MeasurementDecoder): + """Incrementally decode absorbance measurement frames.""" + + STATUS_FRAME_LEN = 31 + + def __init__(self, expected: int) -> None: + super().__init__(expected) + self.measurements: List[_AbsorbanceMeasurement] = [] + self._calibration: Optional[_AbsorbanceCalibration] = None + + @property + def count(self) -> int: + return len(self.measurements) + + @property + def calibration(self) -> Optional[_AbsorbanceCalibration]: + return self._calibration + + def _should_consume_bin(self, payload_len: int) -> bool: + return _is_abs_calibration_len(payload_len) or _is_abs_data_len(payload_len) + + def _handle_bin(self, payload_len: int, blob: bytes) -> None: + if _is_abs_calibration_len(payload_len): + if self._calibration is not None: + return + cal = _decode_abs_calibration(payload_len, blob) + if cal is not None: + self._calibration = cal + return + if _is_abs_data_len(payload_len): + data = _decode_abs_data(payload_len, blob) + if data is None: + return + _label, _ex, items = data + sample, reference = items[0] if items else (0, 0) + self.measurements.append( + _AbsorbanceMeasurement(sample=sample, reference=reference, items=items) + ) + + +# --------------------------------------------------------------------------- +# Fluorescence decoding & calibration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _FluorescenceCalibration: + ex: int + meas_dark: int + ref_dark: int + ref_bright: int + + +def _decode_flr_calibration(payload_len: int, blob: bytes) -> Optional[_FluorescenceCalibration]: + split = _split_payload_and_trailer(payload_len, blob) + if split is None: + return None + payload, _ = split + if len(payload) != 18: + return None + reader = Reader(payload, little_endian=False) + ex = reader.u16() + reader.raw_bytes(8) + meas_dark = reader.u16() + reader.raw_bytes(2) + ref_dark = reader.u16() + ref_bright = reader.u16() + return _FluorescenceCalibration( + ex=ex, meas_dark=meas_dark, ref_dark=ref_dark, ref_bright=ref_bright, + ) + + +def _decode_flr_data( + payload_len: int, blob: bytes +) -> Optional[Tuple[int, int, int, List[Tuple[int, int]]]]: + split = _split_payload_and_trailer(payload_len, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 6: + return None + reader = Reader(payload, little_endian=False) + label = reader.u16() + ex = reader.u16() + em = reader.u16() + items: List[Tuple[int, int]] = [] + while reader.offset() + 10 <= len(payload): + reader.raw_bytes(6) + meas = reader.u16() + ref = reader.u16() + items.append((meas, ref)) + if reader.offset() != len(payload): + return None + return label, ex, em, items + + +def _fluorescence_corrected( + cal: _FluorescenceCalibration, meas_ref_items: List[Tuple[int, int]] +) -> int: + if not meas_ref_items: + return 0 + meas_mean = sum(m for m, _ in meas_ref_items) / len(meas_ref_items) + ref_mean = sum(r for _, r in meas_ref_items) / len(meas_ref_items) + denom = ref_mean - cal.ref_dark + if denom == 0: + return 0 + corr = (meas_mean - cal.meas_dark) * (cal.ref_bright - cal.ref_dark) / denom + return int(round(corr)) + + +class _FluorescenceRunDecoder(_MeasurementDecoder): + """Incrementally decode fluorescence measurement frames.""" + + STATUS_FRAME_LEN = 31 + + def __init__(self, expected_wells: int) -> None: + super().__init__(expected_wells) + self._intensities: List[int] = [] + self._calibration: Optional[_FluorescenceCalibration] = None + + @property + def count(self) -> int: + return len(self._intensities) + + @property + def intensities(self) -> List[int]: + return self._intensities + + def _should_consume_bin(self, payload_len: int) -> bool: + if payload_len == 18: + return True + if payload_len >= 16 and (payload_len - 6) % 10 == 0: + return True + return False + + def _handle_bin(self, payload_len: int, blob: bytes) -> None: + if payload_len == 18: + cal = _decode_flr_calibration(payload_len, blob) + if cal is not None: + self._calibration = cal + return + data = _decode_flr_data(payload_len, blob) + if data is None: + return + _label, _ex, _em, items = data + if self._calibration is not None: + intensity = _fluorescence_corrected(self._calibration, items) + else: + if not items: + intensity = 0 + else: + intensity = int(round(sum(m for m, _ in items) / len(items))) + self._intensities.append(intensity) + + +# --------------------------------------------------------------------------- +# Luminescence decoding & calibration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _LuminescenceCalibration: + ref_dark: int + + +def _decode_lum_calibration(payload_len: int, blob: bytes) -> Optional[_LuminescenceCalibration]: + split = _split_payload_and_trailer(payload_len, blob) + if split is None: + return None + payload, _ = split + if len(payload) != 10: + return None + reader = Reader(payload, little_endian=False) + reader.raw_bytes(6) + return _LuminescenceCalibration(ref_dark=reader.i32()) + + +def _decode_lum_data(payload_len: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: + split = _split_payload_and_trailer(payload_len, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 4: + return None + reader = Reader(payload, little_endian=False) + label = reader.u16() + em = reader.u16() + counts: List[int] = [] + while reader.offset() + 10 <= len(payload): + reader.raw_bytes(6) + counts.append(reader.i32()) + if reader.offset() != len(payload): + return None + return label, em, counts + + +def _luminescence_intensity( + cal: _LuminescenceCalibration, + counts: List[int], + dark_integration_s: float, + meas_integration_s: float, +) -> int: + if not counts: + return 0 + if dark_integration_s == 0 or meas_integration_s == 0: + return 0 + count_mean = sum(counts) / len(counts) + corrected_rate = (count_mean / meas_integration_s) - (cal.ref_dark / dark_integration_s) + return int(corrected_rate) + + +@dataclass +class _LuminescenceMeasurement: + intensity: int + + +class _LuminescenceRunDecoder(_MeasurementDecoder): + """Incrementally decode luminescence measurement frames.""" + + def __init__( + self, + expected: int, + *, + dark_integration_s: float = 0.0, + meas_integration_s: float = 0.0, + ) -> None: + super().__init__(expected) + self.measurements: List[_LuminescenceMeasurement] = [] + self._calibration: Optional[_LuminescenceCalibration] = None + self._dark_integration_s = float(dark_integration_s) + self._meas_integration_s = float(meas_integration_s) + + @property + def count(self) -> int: + return len(self.measurements) + + def _should_consume_bin(self, payload_len: int) -> bool: + if payload_len == 10: + return True + if payload_len >= 14 and (payload_len - 4) % 10 == 0: + return True + return False + + def _handle_bin(self, payload_len: int, blob: bytes) -> None: + if payload_len == 10: + cal = _decode_lum_calibration(payload_len, blob) + if cal is not None: + self._calibration = cal + return + data = _decode_lum_data(payload_len, blob) + if data is None: + return + _label, _em, counts = data + if self._calibration is not None and self._dark_integration_s and self._meas_integration_s: + intensity = _luminescence_intensity( + self._calibration, counts, self._dark_integration_s, self._meas_integration_s + ) + else: + intensity = int(round(sum(counts) / len(counts))) if counts else 0 + self.measurements.append(_LuminescenceMeasurement(intensity=intensity)) 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/temperature_controlling/inheco/cpac_backend.py b/pylabrobot/temperature_controlling/inheco/cpac_backend.py deleted file mode 100644 index 6a9f4715c8f..00000000000 --- a/pylabrobot/temperature_controlling/inheco/cpac_backend.py +++ /dev/null @@ -1,7 +0,0 @@ -from pylabrobot.temperature_controlling.inheco.temperature_controller import ( - InhecoTemperatureControllerBackend, -) - - -class InhecoCPACBackend(InhecoTemperatureControllerBackend): - pass diff --git a/pylabrobot/temperature_controlling/inheco/temperature_controller.py b/pylabrobot/temperature_controlling/inheco/temperature_controller.py deleted file mode 100644 index 6c78abb5a40..00000000000 --- a/pylabrobot/temperature_controlling/inheco/temperature_controller.py +++ /dev/null @@ -1,83 +0,0 @@ -import abc -import warnings - -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox - - -class InhecoTemperatureControllerBackend(TemperatureControllerBackend, metaclass=abc.ABCMeta): - """Universal backend for Inheco Temperature Controller devices such as ThermoShake and CPAC""" - - @property - def supports_active_cooling(self) -> bool: - return True - - def __init__(self, index: int, control_box: InhecoTECControlBox): - assert 1 <= index <= 6, "Index must be between 1 and 6 (inclusive)" - self.index = index - self.interface = control_box - - async def setup(self): - pass - - async def stop(self): - await self.stop_temperature_control() - - def serialize(self) -> dict: - warnings.warn("The interface is not serialized.") - return super().serialize() - - # -- temperature control - - async def set_temperature(self, temperature: float): - await self.set_target_temperature(temperature) - await self.start_temperature_control() - - async def get_current_temperature(self) -> float: - response = await self.interface.send_command(f"{self.index}RAT0") - return float(response) / 10 - - async def deactivate(self): - await self.stop_temperature_control() - - # --- 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): - """Start the temperature control""" - - return await self.interface.send_command(f"{self.index}ATE1") - - async def stop_temperature_control(self): - """Stop the temperature control""" - - return await self.interface.send_command(f"{self.index}ATE0") - - # --- firmware misc - - async def get_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 - """ - - assert info_type in range(5), "Info type must be in the range 0 to 4" - return await self.interface.send_command(f"{self.index}RFV{info_type}") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class InhecoThermoShake: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`InhecoThermoShake` is deprecated. Please use `InhecoThermoShakeBackend` instead. " - ) diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/temperature_controlling/opentrons_backend.py deleted file mode 100644 index 4072ea4ae0e..00000000000 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import cast - -from pylabrobot.temperature_controlling.backend import ( - TemperatureControllerBackend, -) - -try: - import ot_api - - USE_OT = True -except ImportError as e: - USE_OT = False - _OT_IMPORT_ERROR = e - - -class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): - """Opentrons temperature module backend.""" - - @property - def supports_active_cooling(self) -> bool: - return False - - def __init__(self, opentrons_id: str): - """Create a new Opentrons temperature module backend. - - Args: - opentrons_id: Opentrons ID of the temperature module. Get it from - `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. - """ - self.opentrons_id = opentrons_id - - if not USE_OT: - raise RuntimeError( - "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." - f" Import error: {_OT_IMPORT_ERROR}." - ) - - async def setup(self): - pass - - async def stop(self): - await self.deactivate() - - def serialize(self) -> dict: - return {**super().serialize(), "opentrons_id": self.opentrons_id} - - async def set_temperature(self, temperature: float): - ot_api.modules.temperature_module_set_temperature( - celsius=temperature, module_id=self.opentrons_id - ) - - async def deactivate(self): - ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) - - async def get_current_temperature(self) -> float: - modules = ot_api.modules.list_connected_modules() - for module in modules: - if module["id"] == self.opentrons_id: - return cast(float, module["data"]["currentTemperature"]) - raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py deleted file mode 100644 index dd941633d55..00000000000 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Optional - -from pylabrobot.io.serial import Serial -from pylabrobot.temperature_controlling.backend import ( - TemperatureControllerBackend, -) - - -class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): - """Opentrons temperature module backend.""" - - @property - def supports_active_cooling(self) -> bool: - return True - - def __init__(self, port: str): - """Create a new Opentrons temperature module backend. - - Args: - port: Serial port for USB communication. - """ - - self.port = port - self._serial: Optional["Serial"] = None - - @property - def serial(self) -> "Serial": - if self._serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - return self._serial - - async def setup(self): - # Setup serial communication for USB - self._serial = Serial( - human_readable_device_name="Opentrons Temperature Module", - port=self.port, - baudrate=115200, - timeout=3, - ) - await self._serial.setup() - - async def stop(self): - await self.deactivate() - if self._serial is not None: - await self._serial.stop() - self._serial = None - - def serialize(self) -> dict: - return {**super().serialize(), "port": self.port} - - async def set_temperature(self, temperature: float): - tmp_message = f"M104 S{temperature}\r\n" - await self.serial.write(tmp_message.encode("utf-8")) - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) - - async def deactivate(self): - await self.serial.write(b"M18\r\n") - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) - - async def get_current_temperature(self) -> float: - await self.serial.write(b"M105\r\n") - # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") - response = await self.serial.readline() - # Verify we got the expected response - # Read response (should be "ok\r\nok\r\n") - if b"C" not in response: - raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") - - response1 = await self.serial.readline() - response2 = await self.serial.readline() - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) - return float(response.strip().split(b"C:")[-1]) 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/cytomat/__init__.py b/pylabrobot/thermo_fisher/cytomat/__init__.py new file mode 100644 index 00000000000..af6cb483a59 --- /dev/null +++ b/pylabrobot/thermo_fisher/cytomat/__init__.py @@ -0,0 +1,5 @@ +from .backend import CytomatBackend +from .chatterbox import CytomatChatterbox +from .constants import CytomatType +from .cytomat import Cytomat +from .heraeus_backend import HeraeusCytomatBackend diff --git a/pylabrobot/storage/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/backend.py similarity index 66% rename from pylabrobot/storage/cytomat/cytomat.py rename to pylabrobot/thermo_fisher/cytomat/backend.py index ca9227fe003..f6af01f4695 100644 --- a/pylabrobot/storage/cytomat/cytomat.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -12,10 +12,15 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver 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.thermo_fisher.cytomat.constants import ( ActionRegister, ActionType, CytomatActionResponse, @@ -28,20 +33,20 @@ SwapStationPosition, WarningRegister, ) -from pylabrobot.storage.cytomat.errors import ( +from pylabrobot.thermo_fisher.cytomat.errors import ( CytomatBusyError, CytomatCommandUnknownError, CytomatTelegramStructureError, error_map, error_register_map, ) -from pylabrobot.storage.cytomat.schemas import ( +from pylabrobot.thermo_fisher.cytomat.schemas import ( ActionRegisterState, OverviewRegisterState, SensorStates, SwapStationState, ) -from pylabrobot.storage.cytomat.utils import ( +from pylabrobot.thermo_fisher.cytomat.utils import ( hex_to_base_twelve, hex_to_binary, validate_storage_location_number, @@ -50,7 +55,14 @@ logger = logging.getLogger(__name__) -class CytomatBackend(IncubatorBackend): +class CytomatBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + ShakerBackend, + HasContinuousShaking, + Driver, +): default_baud = 9600 serial_message_encoding = "utf-8" @@ -82,6 +94,7 @@ def __init__(self, model: Union[CytomatType, str], port: str): self._racks: List[PlateCarrier] = [] self.io = Serial( + human_readable_device_name="Cytomat", port=port, baudrate=self.default_baud, bytesize=serial.EIGHTBITS, @@ -89,20 +102,115 @@ def __init__(self, model: Union[CytomatType, str], port: str): stopbits=serial.STOPBITS_ONE, write_timeout=1, timeout=1, - human_readable_device_name="Cytomat", ) - async def setup(self): + async def setup(self, backend_params: Optional[BackendParams] = None): + await Driver.setup(self, backend_params=backend_params) await self.io.setup() + logger.info("[Cytomat %s %s] connected", self.model.value, self.io.port) await self.initialize() await self.wait_for_task_completion() + async def stop(self): + await self.io.stop() + logger.info("[Cytomat %s %s] disconnected", self.model.value, self.io.port) + await Driver.stop(self) + async def set_racks(self, racks: List[PlateCarrier]): - await super().set_racks(racks) + self._racks = racks warnings.warn("Cytomat racks need to be configured with the exe software") - async def stop(self): - await self.io.stop() + # -- AutomatedRetrievalBackend -- + + async def fetch_plate_to_loading_tray(self, plate: Plate): + logger.info( + "[Cytomat %s %s] fetch plate to loading tray: plate='%s'", + self.model.value, + self.io.port, + plate.name, + ) + site = plate.parent + assert isinstance(site, PlateHolder) + await self.action_storage_to_transfer(site) + + async def store_plate(self, plate: Plate, site: PlateHolder): + logger.info( + "[Cytomat %s %s] store plate: plate='%s', site='%s'", + self.model.value, + self.io.port, + plate.name, + site.name, + ) + await self.action_transfer_to_storage(site) + + # -- TemperatureControllerBackend -- + + @property + def supports_active_cooling(self) -> bool: + return False + + async def set_temperature(self, temperature: float): + raise NotImplementedError("Temperature is configured via the Cytomat device UI") + + async def request_current_temperature(self) -> float: + temperature = (await self.request_incubation_query("it")).actual_value + logger.info( + "[Cytomat %s %s] read temperature: actual=%.1f C", self.model.value, self.io.port, temperature + ) + return temperature + + async def deactivate(self): + pass # no-op: temperature is device-managed + + # -- HumidityControllerBackend -- + + @property + def supports_humidity_control(self) -> bool: + return False # read-only + + async def set_humidity(self, humidity: float): + raise NotImplementedError("Humidity is configured via the Cytomat device UI") + + async def request_current_humidity(self) -> float: + humidity = (await self.request_incubation_query("ih")).actual_value + logger.info( + "[Cytomat %s %s] read humidity: actual=%.1f %%RH", self.model.value, self.io.port, humidity + ) + return humidity + + # -- ShakerBackend -- + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("Cytomat does not support plate locking") + + async def unlock_plate(self): + raise NotImplementedError("Cytomat does not support plate locking") + + async def shake(self, speed: float, duration: float, backend_params=None): + await self.start_shaking(speed=speed) + try: + await asyncio.sleep(duration) + finally: + await self.stop_shaking() + + async def start_shaking(self, speed: float, shakers: Optional[List[int]] = None): + if self.model == CytomatType.C5C: + raise NotImplementedError("Shaking is not supported on this model") + logger.info("[Cytomat %s %s] start shaking: speed=%.0f", self.model.value, self.io.port, speed) + await self.set_shaking_frequency(frequency=int(speed), shakers=shakers) + return hex_to_binary(await self.send_command("ll", "va", "")) + + async def stop_shaking(self): + if self.model == CytomatType.C5C: + raise NotImplementedError("Shaking is not supported on this model") + logger.info("[Cytomat %s %s] stop shaking", self.model.value, self.io.port) + return hex_to_binary(await self.send_command("ll", "vd", "")) + + # -- Device-specific methods -- def _assemble_command(self, command_type: str, command: str, params: str): carriage_return = "\r" if self.model == CytomatType.C2C_425 else "\r\n" @@ -111,7 +219,6 @@ def _assemble_command(self, command_type: str, command: str, params: str): async def send_command(self, command_type: str, command: str, params: str) -> str: async def _send_command(command_str) -> str: - logger.debug(command_str.encode(self.serial_message_encoding)) await self.io.write(command_str.encode(self.serial_message_encoding)) resp = (await self.io.read(128)).decode(self.serial_message_encoding) if len(resp) == 0: @@ -120,12 +227,17 @@ async def _send_command(command_str) -> str: value = " ".join(values) if key == CytomatActionResponse.OK.value or key == command: - # actions return an OK response, while checks return the command at the start of the response return value if key == CytomatActionResponse.ERROR.value: - logger.error("Command %s failed with: '%s'", command_str, resp) + logger.error( + "[Cytomat %s %s] command %s failed with: '%s'", + self.model.value, + self.io.port, + command_str, + resp, + ) if value == "03": - error_register = await self.get_error_register() + error_register = await self.request_error_register() await self.reset_error_register() raise CytomatTelegramStructureError(f"Telegram structure error: {error_register}") if int(value, base=16) in error_map: @@ -134,13 +246,16 @@ async def _send_command(command_str) -> str: await self.reset_error_register() raise Exception(f"Unknown cytomat error code in response: {resp}") - logger.error("Command %s received an unknown response: '%s'", command_str, resp) + logger.error( + "[Cytomat %s %s] command %s received an unknown response: '%s'", + self.model.value, + self.io.port, + command_str, + resp, + ) await self.reset_error_register() raise Exception(f"Unknown response from cytomat: {resp}") - # Cytomats sometimes return a busy or command not recognized error even when the overview - # register says the machine is not busy, or if the command is known. We will retry a few times, - # which costs 1s if there is a true error, but is necessary to avoid false negatives. command_str = self._assemble_command(command_type=command_type, command=command, params=params) n_retries = 10 exc: Optional[BaseException] = None @@ -158,27 +273,25 @@ async def _send_command(command_str) -> str: async def send_action( self, command_type: str, command: str, params: str, timeout: Optional[int] = 60 ) -> OverviewRegisterState: - """Calls send_command, but has a timeout handler and returns the overview register state. + """Send an action command and wait for completion. + Args: - timeout: The maximum time to wait for the command to complete. If None, the command will not - wait for completion. + timeout: Seconds to wait. Pass None to skip waiting (fire-and-forget), + but note the return value will be a default-constructed OverviewRegisterState. """ await self.send_command(command_type, command, params) if timeout is not None: - overview_register = await self.wait_for_task_completion(timeout=timeout) - return overview_register + return await self.wait_for_task_completion(timeout=timeout) + return await self.request_overview_register() def _site_to_firmware_string(self, site: PlateHolder) -> str: rack = cast(PlateCarrier, site.parent) - rack_idx = [rack.name for rack in self._racks].index( - rack.name - ) # autoreload resistant, should work + rack_idx = [rack.name for rack in self._racks].index(rack.name) site_idx = next(idx for idx, s in rack.sites.items() if s == site) if self.model in [CytomatType.C2C_425]: return f"{str(rack_idx).zfill(2)} {str(site_idx).zfill(2)}" - # TODO: configure all cytomats to use `rack site` format if self.model in [ CytomatType.C6000, CytomatType.C6002, @@ -187,15 +300,11 @@ def _site_to_firmware_string(self, site: PlateHolder) -> str: ]: slots_to_skip = sum(r.capacity for r in self._racks[:rack_idx]) absolute_slot = slots_to_skip + site_idx + 1 # 1-indexed - return f"{absolute_slot:03}" raise ValueError(f"Unsupported Cytomat model: {self.model}") - async def get_overview_register(self) -> OverviewRegisterState: - # Sometimes this command is not recognized and it is not known why. We will retry a few times - # We don't care if the cytomat is still busy, that is actually what we are often checking for. - # We are just gathering state, so just try a little bit later. + async def request_overview_register(self) -> OverviewRegisterState: num_tries = 10 for _ in range(num_tries): try: @@ -205,23 +314,21 @@ async def get_overview_register(self) -> OverviewRegisterState: continue return OverviewRegisterState.from_resp(resp) await self.reset_error_register() - raise CytomatCommandUnknownError("Could not get overview register") + raise CytomatCommandUnknownError("Could not request overview register") - async def get_warning_register(self) -> WarningRegister: + async def request_warning_register(self) -> WarningRegister: hex_value = await self.send_command("ch", "bw", "") for member in WarningRegister: if hex_value == member.value: return member - await self.reset_error_register() raise Exception(f"Unknown warning register value: {hex_value}") - async def get_error_register(self) -> ErrorRegister: + async def request_error_register(self) -> ErrorRegister: hex_value = await self.send_command("ch", "be", "") for member in ErrorRegister: if hex_value == member.value: return member - await self.reset_error_register() raise Exception(f"Unknown error register value: {hex_value}") @@ -229,7 +336,7 @@ async def reset_error_register(self) -> None: await self.send_command("rs", "be", "") async def initialize(self) -> None: - await self.send_action("ll", "in", "", timeout=300) # this command sometimes times out + await self.send_action("ll", "in", "", timeout=300) async def open_door(self): return await self.send_action("ll", "gp", "002") @@ -243,7 +350,7 @@ async def shovel_in(self): async def shovel_out(self): return await self.send_action("ll", "sp", "002") - async def get_action_register(self) -> ActionRegisterState: + async def request_action_register(self) -> ActionRegisterState: hex_value = await self.send_command("ch", "ba", "") binary_repr = hex_to_binary(hex_value) target, action = binary_repr[:3], binary_repr[3:] @@ -264,7 +371,7 @@ async def get_action_register(self) -> ActionRegisterState: return ActionRegisterState(target=target_enum, action=action_enum) - async def get_swap_register(self) -> SwapStationState: + async def request_swap_register(self) -> SwapStationState: value = await self.send_command("ch", "sw", "") return SwapStationState( position=SwapStationPosition(int(value[0])), @@ -272,22 +379,18 @@ async def get_swap_register(self) -> SwapStationState: load_status_at_processor=LoadStatusAtProcessor(int(value[2])), ) - async def get_sensor_register(self) -> SensorStates: + async def request_sensor_register(self) -> SensorStates: hex_value = await self.send_command("ch", "ts", "") binary_values = hex_to_base_twelve(hex_value) return SensorStates( **{member.name: bool(int(binary_values[member.value])) for member in SensorRegister} ) - async def action_transfer_to_storage( # used by insert_plate - self, site: PlateHolder - ) -> OverviewRegisterState: + async def action_transfer_to_storage(self, site: PlateHolder) -> OverviewRegisterState: """Open lift door, retrieve from transfer, close door, place at storage""" return await self.send_action("mv", "ts", self._site_to_firmware_string(site), timeout=120) - async def action_storage_to_transfer( # used by retrieve_plate - self, site: PlateHolder - ) -> OverviewRegisterState: + async def action_storage_to_transfer(self, site: PlateHolder) -> OverviewRegisterState: """Retrieve from storage, open door, move to transfer, close door""" return await self.send_action("mv", "st", self._site_to_firmware_string(site)) @@ -328,7 +431,6 @@ async def action_read_barcode( site_number_a: str, site_number_b: str, ) -> OverviewRegisterState: - # Read barcode of storage locations validate_storage_location_number(site_number_a) validate_storage_location_number(site_number_b) resp = await self.send_command("mv", "sn", f"{site_number_a} {site_number_b}") @@ -336,45 +438,29 @@ async def action_read_barcode( async def wait_for_transfer_station(self, occupied: bool = False): """Wait for the transfer station to be occupied, or unoccupied.""" - while (await self.get_overview_register()).transfer_station_occupied != occupied: + while (await self.request_overview_register()).transfer_station_occupied != occupied: await asyncio.sleep(1) async def wait_for_task_completion(self, timeout=60) -> OverviewRegisterState: - """ - Wait for the cytomat to finish the current task. This is done by checking the overview register - until the busy bit is not set. If the cytomat is busy for too long, a TimeoutError is raised. - If the error bit is set in the overview register, the error register is read and the corresponding - error is raised. - """ start = time.time() while True: - overview_register = await self.get_overview_register() + overview_register = await self.request_overview_register() if not overview_register.busy_bit_set: - # only check for errors once the cytomat is done, so that the user has the chance to - # handle the error and proceed if desired. if overview_register.error_register_set: - error_register = await self.get_error_register() + error_register = await self.request_error_register() await self.reset_error_register() raise error_register_map[error_register] return overview_register await asyncio.sleep(1) if time.time() - start > timeout: + logger.error( + "[Cytomat %s %s] task timed out after %ds", self.model.value, self.io.port, timeout + ) raise TimeoutError("Cytomat did not complete task in time") async def init_shakers(self): return hex_to_binary(await self.send_command("ll", "vi", "")) - async def start_shaking(self, frequency: float, shakers: Optional[List[int]] = None): - if self.model == CytomatType.C5C: - raise NotImplementedError("Shaking is not supported on this model") - await self.set_shaking_frequency(frequency=int(frequency), shakers=shakers) - return hex_to_binary(await self.send_command("ll", "va", "")) - - async def stop_shaking(self): - if self.model == CytomatType.C5C: - raise NotImplementedError("Shaking is not supported on this model") - return hex_to_binary(await self.send_command("ll", "vd", "")) - async def set_shaking_frequency( self, frequency: int, shakers: Optional[List[int]] = None ) -> List[str]: @@ -382,7 +468,7 @@ async def set_shaking_frequency( assert all(shaker in [1, 2] for shaker in shakers), "Shaker index must be 1 or 2" return [await self.send_command("se", f"pb 2{idx - 1}", f"{frequency:04}") for idx in shakers] - async def get_incubation_query( + async def request_incubation_query( self, query: Literal["ic", "ih", "io", "it"] ) -> CytomatIncupationResponse: resp = await self.send_command("ch", query, "") @@ -391,61 +477,21 @@ async def get_incubation_query( nominal_value=float(nominal.lstrip("+")), actual_value=float(actual.lstrip("+")) ) - async def get_co2(self) -> CytomatIncupationResponse: - return await self.get_incubation_query("ic") + async def request_co2(self) -> CytomatIncupationResponse: + return await self.request_incubation_query("ic") - async def get_humidity(self) -> CytomatIncupationResponse: - return await self.get_incubation_query("ih") + async def request_humidity(self) -> CytomatIncupationResponse: + return await self.request_incubation_query("ih") - async def get_o2(self) -> CytomatIncupationResponse: - return await self.get_incubation_query("io") + async def request_o2(self) -> CytomatIncupationResponse: + return await self.request_incubation_query("io") - async def get_temperature(self) -> float: - return (await self.get_incubation_query("it")).actual_value - - async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): - site = plate.parent - assert isinstance(site, PlateHolder) - await self.action_storage_to_transfer(site) - - async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): - await self.action_transfer_to_storage(site) - - async def set_temperature(self, *args, **kwargs): - raise NotImplementedError("Temperature control is not implemented yet") + async def request_temperature(self) -> float: + return (await self.request_incubation_query("it")).actual_value def serialize(self) -> dict: return { - **IncubatorBackend.serialize(self), + **Driver.serialize(self), "model": self.model.value, "port": self.io.port, } - - -class CytomatChatterbox(CytomatBackend): - async def setup(self): - await self.wait_for_task_completion() - - async def stop(self): - print("closing connection to cytomat") - - async def send_command(self, command_type, command, params): - print( - "cytomat", self._assemble_command(command_type=command_type, command=command, params=params) - ) - if command_type == "ch": - return "0" - return "0" * 8 - - async def wait_for_transfer_station(self, occupied: bool = False): - # send the command, but don't wait when we are in chatting mode. - _ = await self.get_overview_register() - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class Cytomat: - def __init__(self, *args, **kwargs): - raise RuntimeError("`Cytomat` is deprecated. Please use `CytomatBackend` instead. ") diff --git a/pylabrobot/thermo_fisher/cytomat/chatterbox.py b/pylabrobot/thermo_fisher/cytomat/chatterbox.py new file mode 100644 index 00000000000..eb10c5dd395 --- /dev/null +++ b/pylabrobot/thermo_fisher/cytomat/chatterbox.py @@ -0,0 +1,23 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.thermo_fisher.cytomat.backend import CytomatBackend + + +class CytomatChatterbox(CytomatBackend): + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.wait_for_task_completion() + + async def stop(self): + print("closing connection to cytomat") + + async def send_command(self, command_type, command, params): + print( + "cytomat", self._assemble_command(command_type=command_type, command=command, params=params) + ) + if command_type == "ch": + return "0" + return "0" * 8 + + async def wait_for_transfer_station(self, occupied: bool = False): + _ = await self.request_overview_register() diff --git a/pylabrobot/storage/cytomat/constants.py b/pylabrobot/thermo_fisher/cytomat/constants.py similarity index 97% rename from pylabrobot/storage/cytomat/constants.py rename to pylabrobot/thermo_fisher/cytomat/constants.py index 29f68ec0c2e..410f88108ba 100644 --- a/pylabrobot/storage/cytomat/constants.py +++ b/pylabrobot/thermo_fisher/cytomat/constants.py @@ -137,10 +137,6 @@ class CytomatRack: num_slots: int # number of plate locations in rack pitch: int # distance between 2 plate locations - @classmethod - def deserialize(cls, data: dict): - return cls(num_slots=data["num_slots"], pitch=data["pitch"]) - class CytomatType(Enum): C6000 = "C6000" diff --git a/pylabrobot/thermo_fisher/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/cytomat.py new file mode 100644 index 00000000000..3a44a3c42d9 --- /dev/null +++ b/pylabrobot/thermo_fisher/cytomat/cytomat.py @@ -0,0 +1,194 @@ +import random +from typing import List, Literal, Optional, Union, cast + +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling import HumidityController +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) + +from .backend import CytomatBackend +from .constants import CytomatType + + +class NoFreeSiteError(Exception): + pass + + +class Cytomat(Resource, Device): + _racks: List[PlateCarrier] + driver: CytomatBackend + loading_tray: PlateHolder + retrieval: AutomatedRetrieval + tc: TemperatureController + humidity: HumidityController + shaker: Shaker + + def __init__( + self, + name: str, + driver: CytomatBackend, + racks: List[PlateCarrier], + loading_tray_location: Coordinate, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + raise NotImplementedError("Cytomat resource definition is not verified.") + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, driver=driver) + self.driver: CytomatBackend = driver + + self.loading_tray = PlateHolder( + name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + + self._racks = racks + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + self.retrieval = AutomatedRetrieval(backend=driver) + self.tc = TemperatureController(backend=driver) + self.humidity = HumidityController(backend=driver) + + caps = [self.tc, self.humidity, self.retrieval] + + if driver.model != CytomatType.C5C: + self.shaker = Shaker(backend=driver) + caps.append(self.shaker) + + self._capabilities = caps + + @property + def racks(self) -> List[PlateCarrier]: + return self._racks + + async def setup(self, backend_params: Optional[BackendParams] = None, **backend_kwargs): + await super().setup(backend_params=backend_params) + await self.driver.set_racks(self._racks) + + def get_num_free_sites(self) -> int: + return sum([len(rack.get_free_sites()) for rack in self._racks]) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name} not found in '{self.name}'") + + async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + """Fetch a plate from storage and put it on the loading tray.""" + site = self.get_site_by_plate_name(plate_name) + plate = site.resource + assert plate is not None + await self.retrieval.fetch_plate_to_loading_tray(plate) + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + def _plate_height(p: Plate): + if p.has_lid(): + return p.get_size_z() + 3 + return p.get_size_z() + + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= _plate_height(plate) + ] + if len(available) == 0: + raise NoFreeSiteError(f"No free site found in '{self.name}' for plate '{plate.name}'") + return sorted(available, key=lambda site: site.get_size_z()) + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + """Take a plate from the loading tray and put it in storage.""" + plate = cast(Plate, self.loading_tray.resource) + if plate is None: + raise ResourceNotFoundError(f"No plate on the loading tray of '{self.name}'") + + if site == "random": + site = self.find_random_site(plate) + elif site == "smallest": + site = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name} is not available for plate {plate.name}") + else: + raise ValueError(f"Invalid site: {site}") + await self.retrieval.store_plate(plate, site) + plate.unassign() + site.assign_child_resource(plate) + + def summary(self) -> str: + def create_pretty_table(header, *columns) -> str: + col_widths = [ + max(len(str(item)) for item in [header[i]] + list(columns[i])) for i in range(len(header)) + ] + + def format_row(row, border="|") -> str: + return ( + f"{border} " + + " | ".join(f"{str(row[i]).ljust(col_widths[i])}" for i in range(len(row))) + + f" {border}" + ) + + def separator_line(cross: str = "+", line: str = "-") -> str: + return cross + cross.join(line * (width + 2) for width in col_widths) + cross + + table = [] + table.append(separator_line()) + table.append(format_row(header)) + table.append(separator_line()) + for row in zip(*columns): + table.append(format_row(row)) + table.append(separator_line()) + return "\n".join(table) + + header = [f"Rack {i}" for i in range(len(self._racks))] + sites = [ + [site.resource.name if site.resource else "" for site in reversed(rack.sites.values())] + for rack in self._racks + ] + return create_pretty_table(header, *sites) + + def serialize(self): + from pylabrobot.serializer import serialize + + return { + **Device.serialize(self), + **Resource.serialize(self), + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + } diff --git a/pylabrobot/storage/cytomat/errors.py b/pylabrobot/thermo_fisher/cytomat/errors.py similarity index 98% rename from pylabrobot/storage/cytomat/errors.py rename to pylabrobot/thermo_fisher/cytomat/errors.py index 2f493349a6a..ef3955bf845 100644 --- a/pylabrobot/storage/cytomat/errors.py +++ b/pylabrobot/thermo_fisher/cytomat/errors.py @@ -1,6 +1,6 @@ from typing import Dict -from pylabrobot.storage.cytomat.constants import ErrorRegister +from pylabrobot.thermo_fisher.cytomat.constants import ErrorRegister class CytomatBusyError(Exception): diff --git a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py similarity index 58% rename from pylabrobot/storage/cytomat/heraeus_cytomat_backend.py rename to pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index a991d55fc59..f9a07687c68 100644 --- a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -2,7 +2,7 @@ import logging import time import warnings -from typing import List, Tuple +from typing import List, Optional, Tuple try: import serial @@ -12,15 +12,27 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.shaking.backend import HasContinuousShaking, ShakerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.backend import IncubatorBackend logger = logging.getLogger(__name__) -class HeraeusCytomatBackend(IncubatorBackend): +class HeraeusCytomatBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + ShakerBackend, + HasContinuousShaking, + Driver, +): """ Backend for legacy (Heraeus) Cytomats. Perhaps identical to Liconic backend... @@ -43,7 +55,9 @@ def __init__(self, port: str): f"Import error: {_SERIAL_IMPORT_ERROR}" ) super().__init__() + self._racks: List[PlateCarrier] = [] self.io = Serial( + human_readable_device_name="Heraeus Cytomat", port=port, baudrate=self.default_baud, bytesize=serial.EIGHTBITS, @@ -52,23 +66,16 @@ def __init__(self, port: str): write_timeout=1, timeout=1, rtscts=True, - human_readable_device_name="Heraeus Cytomat", ) - async def setup(self) -> Serial: - """ - 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. - 2. Send >200 ms break, wait 150 ms, flush buffers. - 3. Handshake: CR → wait for CC - 4. Activate handling: ST 1801 → expect OK - 5. Poll ready-flag: RD 1915 → wait for "1" - """ + async def setup(self, backend_params: Optional[BackendParams] = None): + await Driver.setup(self, backend_params=backend_params) try: await self.io.setup() except serial.SerialException as e: raise RuntimeError(f"Could not open {self.io.port}: {e}") - await self.io.send_break(duration=0.2) # >100 ms required + await self.io.send_break(duration=0.2) await asyncio.sleep(0.15) await self.io.reset_input_buffer() await self.io.reset_output_buffer() @@ -76,10 +83,12 @@ async def setup(self) -> Serial: await self.io.write(b"CR\r") deadline = time.time() + self.init_timeout while time.time() < deadline: - resp = await self.io.readline() # reads through LF + resp = await self.io.readline() if resp.strip() == b"CC": + logger.info("[Heraeus %s] connected", self.io.port) break else: + logger.error("No CC response from PLC within %ss", self.init_timeout) await self.io.stop() raise TimeoutError(f"No CC response from PLC within {self.init_timeout} seconds") @@ -87,6 +96,7 @@ async def setup(self) -> Serial: resp = await self.io.readline() if resp.strip() != b"OK": await self.io.stop() + logger.error("[Heraeus %s] unexpected reply to ST 1801: %r", self.io.port, resp) raise RuntimeError(f"Unexpected reply to ST 1801: {resp!r}") deadline = time.time() + self.start_timeout @@ -94,7 +104,7 @@ async def setup(self) -> Serial: await self.io.write(b"RD 1915\r") flag = await self.io.readline() if flag.strip() == b"1": - return self.io + return await asyncio.sleep(self.poll_interval) await self.io.stop() @@ -102,78 +112,112 @@ async def setup(self) -> Serial: async def stop(self): await self.io.stop() + logger.info("[Heraeus %s] disconnected", self.io.port) + await Driver.stop(self) async def set_racks(self, racks: List[PlateCarrier]): - await super().set_racks(racks) + self._racks = racks warnings.warn("Cytomat racks need to be configured manually on each setup") - async def initialize(self): - await self._send_command("ST 1900") - await self._send_command("ST 1801") - await self._wait_ready() - - async def open_door(self): - await self._send_command("ST 1901") - await self._wait_ready() - - async def close_door(self): - await self._send_command("ST 1902") - await self._wait_ready() + # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): - """Fetch a plate from storage onto the transfer station, with gate open/close.""" + async def fetch_plate_to_loading_tray(self, plate: Plate): + logger.info("[Heraeus %s] fetch plate to loading tray: plate='%s'", self.io.port, plate.name) site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" m, n = self._site_to_m_n(site) - await self._send_command(f"WR DM0 {m}") # carousel pos - await self._send_command(f"WR DM5 {n}") # handler level - await self._send_command("ST 1905") # plate to transfer station + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1905") await self._wait_ready() - await self._send_command("ST 1903") # terminate access + await self._send_command("ST 1903") - async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): - """Place a plate from the transfer station into storage at the given site.""" + async def store_plate(self, plate: Plate, site: PlateHolder): + logger.info( + "[Heraeus %s] store plate: plate='%s', site='%s'", self.io.port, plate.name, site.name + ) m, n = self._site_to_m_n(site) - await self._send_command(f"WR DM0 {m}") # carousel pos - await self._send_command(f"WR DM5 {n}") # handler level - await self._send_command("ST 1904") # plate to storage + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1904") await self._wait_ready() - await self._send_command("ST 1903") # terminate access + await self._send_command("ST 1903") + + # -- TemperatureControllerBackend -- + + @property + def supports_active_cooling(self) -> bool: + return False async def set_temperature(self, temperature: float): raise NotImplementedError("Temperature control not implemented yet") - async def get_temperature(self) -> float: + async def request_current_temperature(self) -> float: raise NotImplementedError("Temperature query not implemented yet") - async def start_shaking(self, frequency: float = 1.0): + async def deactivate(self): + pass + + # -- HumidityControllerBackend -- + + @property + def supports_humidity_control(self) -> bool: + return False + + async def set_humidity(self, humidity: float): + raise NotImplementedError("Humidity control not implemented yet") + + async def request_current_humidity(self) -> float: + raise NotImplementedError("Humidity query not implemented yet") + + # -- ShakerBackend -- + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("Heraeus Cytomat does not support plate locking") + + async def unlock_plate(self): + raise NotImplementedError("Heraeus Cytomat does not support plate locking") + + async def shake(self, speed: float, duration: float, backend_params=None): + await self.start_shaking(speed=speed) + try: + await asyncio.sleep(duration) + finally: + await self.stop_shaking() + + async def start_shaking(self, speed: float): + logger.info("[Heraeus %s] start shaking", self.io.port) await self._send_command("ST 1607") await self._wait_ready() async def stop_shaking(self): + logger.info("[Heraeus %s] stop shaking", self.io.port) await self._send_command("RS 1607") await self._wait_ready() + # -- Device-specific methods -- + def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: rack = site.parent assert isinstance(rack, PlateCarrier), "Site not in rack" assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, cytomat is 1-indexed - site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed + rack_idx = self._racks.index(rack) + 1 + site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 return rack_idx, site_idx async def _send_command(self, command: str) -> str: - """ - Send an ASCII command (without CR) and return the raw response string. - """ cmd = command.strip() + "\r" - logger.debug("Sending Cytomat command: %r", cmd) await self.io.write(cmd.encode(self.serial_message_encoding)) resp = (await self.io.read(128)).decode(self.serial_message_encoding) if not resp: raise RuntimeError("No response from Cytomat controller") resp = resp.strip() if resp.startswith("E"): + logger.error("[Heraeus %s] controller error: %s", self.io.port, resp) raise RuntimeError(f"Cytomat controller error: {resp}") return resp @@ -182,14 +226,10 @@ async def wait_for_transfer_station(self, occupied: bool = False): await asyncio.sleep(1) async def read_plate_detection_xfer(self) -> bool: - """Read Plate Detection Transfer Station (RD 1813).""" resp = await self._send_command("RD 1813") return resp == "1" async def _wait_ready(self, timeout: int = 60): - """ - Poll the ready flag (RD 1915) until it becomes '1' or timeout. - """ start = time.time() while True: resp = await self._send_command("RD 1915") @@ -197,14 +237,24 @@ async def _wait_ready(self, timeout: int = 60): return await asyncio.sleep(0.1) if time.time() - start > timeout: + logger.error("[Heraeus %s] timed out waiting for ready after %ds", self.io.port, timeout) raise TimeoutError("Legacy Cytomat did not become ready in time") + async def initialize(self): + await self._send_command("ST 1900") + await self._send_command("ST 1801") + await self._wait_ready() + + async def open_door(self): + await self._send_command("ST 1901") + await self._wait_ready() + + async def close_door(self): + await self._send_command("ST 1902") + await self._wait_ready() + def serialize(self) -> dict: return { - **super().serialize(), + **Driver.serialize(self), "port": self.io.port, } - - @classmethod - def deserialize(cls, data: dict): - return cls(port=data["port"]) diff --git a/pylabrobot/storage/cytomat/racks.py b/pylabrobot/thermo_fisher/cytomat/racks.py similarity index 100% rename from pylabrobot/storage/cytomat/racks.py rename to pylabrobot/thermo_fisher/cytomat/racks.py diff --git a/pylabrobot/storage/cytomat/schemas.py b/pylabrobot/thermo_fisher/cytomat/schemas.py similarity index 92% rename from pylabrobot/storage/cytomat/schemas.py rename to pylabrobot/thermo_fisher/cytomat/schemas.py index b32f85c34ac..b6831fcebaa 100644 --- a/pylabrobot/storage/cytomat/schemas.py +++ b/pylabrobot/thermo_fisher/cytomat/schemas.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from pylabrobot.storage.cytomat.constants import ( +from pylabrobot.thermo_fisher.cytomat.constants import ( ActionRegister, ActionType, LoadStatusAtProcessor, @@ -8,7 +8,7 @@ OverviewRegister, SwapStationPosition, ) -from pylabrobot.storage.cytomat.utils import hex_to_binary +from pylabrobot.thermo_fisher.cytomat.utils import hex_to_binary @dataclass(frozen=True) diff --git a/pylabrobot/storage/cytomat/utils.py b/pylabrobot/thermo_fisher/cytomat/utils.py similarity index 100% rename from pylabrobot/storage/cytomat/utils.py rename to pylabrobot/thermo_fisher/cytomat/utils.py diff --git a/pylabrobot/thermo_fisher/multidrop_combi/__init__.py b/pylabrobot/thermo_fisher/multidrop_combi/__init__.py new file mode 100644 index 00000000000..1dd91f94632 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/__init__.py @@ -0,0 +1,21 @@ +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.enums import ( + CassetteType, + DispensingOrder, + EmptyMode, + PrimeMode, +) +from pylabrobot.thermo_fisher.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiError, + MultidropCombiInstrumentError, +) +from pylabrobot.thermo_fisher.multidrop_combi.helpers import ( + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) +from pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi import MultidropCombi +from pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8 import ( + MultidropCombiPeristalticDispensingBackend8, +) diff --git a/pylabrobot/thermo_fisher/multidrop_combi/driver.py b/pylabrobot/thermo_fisher/multidrop_combi/driver.py new file mode 100644 index 00000000000..17653b057d9 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/driver.py @@ -0,0 +1,307 @@ +"""Driver for the Thermo Scientific Multidrop Combi.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +from pylabrobot.io.serial import Serial +from pylabrobot.thermo_fisher.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiInstrumentError, +) + +logger = logging.getLogger(__name__) + +STATUS_OK = 0 + +ERROR_DESCRIPTIONS = { + 1: "Internal firmware error", + 2: "Unrecognized command", + 3: "Invalid command arguments", + 4: "Pump position error", + 5: "Plate X position error", + 6: "Plate Y position error", + 7: "Z position error", + 9: "Attempt to reset serial number", + 10: "Nonvolatile parameters lost", + 11: "No more memory for user data", + 12: "Pump or X motor was running", + 13: "X and Z positions conflict", + 14: "Cannot dispense: pump not primed", + 15: "Missing prime vessel", + 16: "Rotor shield not in place", + 17: "Dispense volume for all columns is 0", + 18: "Invalid plate type (bad plate index)", + 19: "Plate has not been defined", + 20: "Invalid rows in plate definition", + 21: "Invalid columns in plate definition", + 22: "Plate height is invalid", + 23: "Plate well volume invalid (too small or too big)", + 24: "Invalid cassette type (bad cassette index)", + 25: "Cassette not defined", + 26: "Invalid volume increment for cassette", + 27: "Invalid maximum volume for cassette", + 28: "Invalid minimum volume for cassette", + 29: "Invalid min/max pump speed for cassette", + 30: "Invalid pump rotor offset in cassette definition", + 32: "Dispensing volume not within cassette limits", + 33: "Invalid selector channel", + 34: "Invalid dispensing speed", + 35: "Dispensing height too low for plate", + 36: "Predispense volume not within cassette limits", + 37: "Invalid dispensing order", + 38: "Invalid X or Y dispensing offset", + 39: "RFID option not present", + 40: "RFID tag not present", + 41: "RFID tag data checksum incorrect", + 43: "Wrong cassette type", + 44: "Protocol/plate in use, cannot modify or delete", + 45: "Protocol/plate/cassette is read-only", +} + + +class MultidropCombiDriver(Driver): + """Driver for the Thermo Scientific Multidrop Combi reagent dispenser. + + Owns the serial connection and provides generic command transport, + device-level operations (send_abort_signal, restart, acknowledge_error), and queries. + + Communication is via RS232/USB serial at 9600 baud, 8N1. + + Args: + port: Serial port (e.g. "COM3", "/dev/ttyUSB0"). + timeout: Default serial read timeout in seconds. + """ + + def __init__( + self, + port: str, + timeout: float = 30.0, + ) -> None: + super().__init__() + self._port = port + self.timeout = timeout + self.io = Serial( + human_readable_device_name="Multidrop Combi", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + write_timeout=5, + xonxoff=True, + ) + self._command_lock: Optional[asyncio.Lock] = None + self._instrument_name: str = "" + self._firmware_version: str = "" + self._serial_number: str = "" + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + self._command_lock = asyncio.Lock() + await self.io.setup() + await self._drain_stale_data() + + info = await self._enter_remote_mode() + self._instrument_name = info["instrument_name"] + self._firmware_version = info["firmware_version"] + self._serial_number = info["serial_number"] + + logger.info( + "Connected to %s (FW: %s, SN: %s)", + self._instrument_name, + self._firmware_version, + self._serial_number, + ) + + async def stop(self) -> None: + await self._exit_remote_mode() + await self.io.stop() + self._command_lock = None + + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self._port, + "timeout": self.timeout, + } + + # --- Command transport --- + + async def send_command(self, cmd: str, timeout: float | None = None) -> list[str]: + """Send a command and return the data lines from the response. + + Args: + cmd: Command string (e.g. "DIS", "SPL 1", "SCV 0 500"). + timeout: Per-command read timeout in seconds. If None, uses default. + + Returns: + List of data lines (between echo and END terminator). + + Raises: + MultidropCombiCommunicationError: If not connected or communication fails. + MultidropCombiInstrumentError: If instrument returns non-zero status code. + """ + if self._command_lock is None: + raise MultidropCombiCommunicationError("Not connected to instrument", operation=cmd) + + cmd_code = cmd.split()[0] + + async with self._command_lock: + with self.io.temporary_timeout(timeout) if timeout is not None else contextlib.nullcontext(): + try: + logger.debug("TX: %r", cmd) + await self.io.write(f"{cmd}\r".encode("ascii")) + + lines: list[str] = [] + while True: + raw = await self.io.readline() + if not raw: + raise MultidropCombiCommunicationError( + f"Timeout reading response for {cmd_code}", operation=cmd + ) + line = raw.decode("ascii", errors="replace").strip() + logger.debug("RX: %r", line) + if not line: + continue + lines.append(line) + + if line.startswith(cmd_code) and " END " in line: + break + + # Parse status from END terminator + end_line = lines[-1] + parts = end_line.split() + status_code = int(parts[-1]) if parts[-1].isdigit() else -1 + + if status_code != STATUS_OK: + desc = ERROR_DESCRIPTIONS.get(status_code, "Unknown error") + logger.error( + "Command %s failed (status %d). RX lines: %s", cmd_code, status_code, lines + ) + raise MultidropCombiInstrumentError(status_code, desc) + + # Return data lines: skip echo (first) and END line (last) + data_lines = [] + for line in lines[:-1]: + line_upper = line.strip().upper() + if line_upper == cmd.strip().upper() or line_upper == cmd_code.upper(): + continue + data_lines.append(line) + + return data_lines + + except (MultidropCombiCommunicationError, MultidropCombiInstrumentError): + raise + except Exception as e: + raise MultidropCombiCommunicationError( + f"Communication error during {cmd_code}: {e}", + operation=cmd, + original_error=e, + ) from e + + # --- Device-level operations --- + + async def send_abort_signal(self) -> None: + """Send ESC character to abort the current operation.""" + await self.io.write(b"\x1b") + + async def restart(self) -> None: + """Restart the instrument (equivalent to power cycle).""" + await self.send_command("RST", timeout=10.0) + + async def acknowledge_error(self) -> None: + """Clear instrument error state.""" + await self.send_command("EAK", timeout=5.0) + + # --- Queries --- + + def get_version(self) -> dict: + """Return cached instrument identification info. + + Returns: + Dict with keys: instrument_name, firmware_version, serial_number. + """ + return { + "instrument_name": self._instrument_name, + "firmware_version": self._firmware_version, + "serial_number": self._serial_number, + } + + async def report_parameters(self) -> list[str]: + """Report instrument parameters (REP command).""" + return await self.send_command("REP", timeout=10.0) + + async def read_error_log(self) -> list[str]: + """Read the instrument error log (LOG command).""" + return await self.send_command("LOG", timeout=10.0) + + async def read_cassette_info(self) -> list[str]: + """Read RFID cassette info (RIR command).""" + return await self.send_command("RIR", timeout=5.0) + + # --- Internal helpers --- + + async def _drain_stale_data(self) -> None: + """Drain any stale data from the serial buffer.""" + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + drained = 0 + with self.io.temporary_timeout(0.3): + while True: + stale = await self.io.readline() + if not stale: + break + drained += 1 + logger.debug("Drained stale data: %r", stale) + if drained: + logger.info("Drained %d stale lines from serial buffer", drained) + + async def _enter_remote_mode(self) -> dict: + """Send VER to enter remote control mode and get instrument info.""" + try: + lines = await self.send_command("VER", timeout=5.0) + except Exception as first_err: + logger.warning("VER failed (%s), sending EAK and retrying...", first_err) + try: + await self.send_command("EAK", timeout=5.0) + except Exception: + pass + try: + lines = await self.send_command("VER", timeout=5.0) + except Exception as e: + raise MultidropCombiCommunicationError( + f"VER command failed: {e}", operation="VER", original_error=e + ) from e + + info = { + "instrument_name": "Unknown", + "firmware_version": "Unknown", + "serial_number": "Unknown", + } + if lines: + raw = lines[0] + if raw.upper().startswith("VER "): + raw = raw[4:] + parts = raw.split() + if len(parts) > 0: + info["instrument_name"] = parts[0] + if len(parts) > 1: + info["firmware_version"] = parts[1] + if len(parts) > 2: + info["serial_number"] = parts[2] + + return info + + async def _exit_remote_mode(self) -> None: + """Send QIT to exit remote control mode.""" + try: + await self.send_command("QIT", timeout=5.0) + except Exception: + pass diff --git a/pylabrobot/thermo_fisher/multidrop_combi/enums.py b/pylabrobot/thermo_fisher/multidrop_combi/enums.py new file mode 100644 index 00000000000..a1c9582fda3 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/enums.py @@ -0,0 +1,41 @@ +import enum + + +class CassetteType(enum.IntEnum): + STANDARD = 0 + SMALL = 1 + USER_DEFINED_1 = 2 + USER_DEFINED_2 = 3 + + +class DispensingOrder(enum.IntEnum): + """Controls the order in which the cassette's tips traverse wells on the plate. + + Only applicable to 384-well and 1536-well plates. For 96-well plates, the + cassette fills all rows in a column simultaneously, so this setting has no effect. + + The cassette has 8 tips (one per row). On 384+ plates, multiple passes are needed + per column. This setting determines the pass order: + + - ROW_WISE (0): A1 → A2 → A3 → ... → A12, then B1 → B2 → ... (fill across columns + within each row before moving to the next row). + - COLUMN_WISE (1): A1 → B1 → ... → H1, then A2 → B2 → ... (fill down rows within + each column before moving to the next column). + + Per-column volumes (set via SCV) are independent of dispensing order. + """ + + ROW_WISE = 0 + COLUMN_WISE = 1 + + +class PrimeMode(enum.IntEnum): + STANDARD = 0 + CONTINUOUS = 1 + STOP_CONTINUOUS = 2 + CALIBRATION = 3 + + +class EmptyMode(enum.IntEnum): + STANDARD = 0 + CONTINUOUS = 1 diff --git a/pylabrobot/thermo_fisher/multidrop_combi/errors.py b/pylabrobot/thermo_fisher/multidrop_combi/errors.py new file mode 100644 index 00000000000..0284adaf58a --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/errors.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +class MultidropCombiError(Exception): + """Base exception for Multidrop Combi errors.""" + + +class MultidropCombiCommunicationError(MultidropCombiError): + """Serial communication failure (port not found, timeout, connection lost).""" + + def __init__( + self, message: str, operation: str = "", original_error: Exception | None = None + ) -> None: + self.operation = operation + self.original_error = original_error + super().__init__(message) + + +class MultidropCombiInstrumentError(MultidropCombiError): + """Instrument returned a non-zero status code.""" + + def __init__(self, status_code: int, description: str) -> None: + self.status_code = status_code + self.description = description + super().__init__(f"Instrument error (status {status_code}): {description}") diff --git a/pylabrobot/thermo_fisher/multidrop_combi/helpers.py b/pylabrobot/thermo_fisher/multidrop_combi/helpers.py new file mode 100644 index 00000000000..1ed4e072739 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/helpers.py @@ -0,0 +1,139 @@ +"""Plate type helpers for the Multidrop Combi. + +Maps PyLabRobot Plate resources to Multidrop Combi plate type indices and +PLA (remote plate definition) command parameters. +""" + +from __future__ import annotations + +from pylabrobot.resources import Plate + +# Multidrop Combi factory plate type definitions (from manual Table 3-3). +# Type index → (well_count, max_plate_height_mm) +# Heights are upper bounds for selecting the best-fit factory type. +_FACTORY_96_WELL_TYPES = [ + # (type_index, max_height_mm) + (0, 18.0), # Type 0: 96-well, 15mm + (1, 30.0), # Type 1: 96-well, 22mm + (2, 55.0), # Type 2: 96-well, 44mm +] + +_FACTORY_384_WELL_TYPES = [ + (3, 8.5), # Type 3: 384-well, 7.5mm + (4, 12.0), # Type 4: 384-well, 10mm + (5, 18.0), # Type 5: 384-well, 15mm + (6, 30.0), # Type 6: 384-well, 22mm + (7, 55.0), # Type 7: 384-well, 44mm +] + +_FACTORY_1536_WELL_TYPES = [ + (8, 7.0), # Type 8: 1536-well, 5mm + (9, 55.0), # Type 9: 1536-well, 10.5mm +] + +# Hardware limits +MAX_COLUMNS = 48 +MAX_ROWS = 32 +MIN_HEIGHT_HUNDREDTHS_MM = 500 # 5mm +MAX_HEIGHT_HUNDREDTHS_MM = 5500 # 55mm +MAX_VOLUME_TENTHS_UL = 25000 # 2500 uL + + +def plate_to_type_index(plate: Plate) -> int: + """Map a PLR Plate to the best-fit Multidrop Combi factory plate type index. + + Selects the factory type based on well count and plate height (size_z). + The smallest factory type whose height threshold accommodates the plate is chosen. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Factory plate type index (0-9). + + Raises: + ValueError: If the plate well count is not 96, 384, or 1536, or if the + plate height exceeds all factory type thresholds. + """ + wells = plate.num_items + height_mm = plate.get_size_z() + + if wells == 96: + type_list = _FACTORY_96_WELL_TYPES + elif wells == 384: + type_list = _FACTORY_384_WELL_TYPES + elif wells == 1536: + type_list = _FACTORY_1536_WELL_TYPES + else: + raise ValueError( + f"Unsupported well count: {wells}. " + "Multidrop factory types support 96, 384, or 1536 wells. " + "Use plate_to_pla_params() for custom plate definitions." + ) + + for type_index, max_height in type_list: + if height_mm <= max_height: + return type_index + + raise ValueError( + f"Plate height {height_mm}mm exceeds all factory type thresholds for {wells}-well plates." + ) + + +def plate_to_pla_params(plate: Plate) -> dict: + """Convert a PLR Plate to Multidrop Combi PLA command parameters. + + Use this for plates that don't match factory types (types 0-9), or when you + want precise control over the plate definition sent to the instrument. + The returned dict can be passed directly to ``backend.define_plate(**params)``. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Dict with keys matching ``define_plate()`` parameters: + column_positions, row_positions, rows, columns, height, max_volume. + + Raises: + ValueError: If any parameter exceeds Multidrop hardware limits. + """ + columns = plate.num_items_x + rows = plate.num_items_y + height_hundredths = round(plate.get_size_z() * 100) + + # Get max_volume from first well (in uL) + first_well = plate.get_well("A1") + well_max_volume_ul = first_well.max_volume + + # Validate against hardware limits + if columns > MAX_COLUMNS: + raise ValueError(f"Plate has {columns} columns, but Multidrop supports at most {MAX_COLUMNS}.") + if rows > MAX_ROWS: + raise ValueError(f"Plate has {rows} rows, but Multidrop supports at most {MAX_ROWS}.") + if height_hundredths < MIN_HEIGHT_HUNDREDTHS_MM: + raise ValueError( + f"Plate height {plate.get_size_z()}mm is below minimum {MIN_HEIGHT_HUNDREDTHS_MM / 100}mm." + ) + if height_hundredths > MAX_HEIGHT_HUNDREDTHS_MM: + raise ValueError( + f"Plate height {plate.get_size_z()}mm exceeds maximum {MAX_HEIGHT_HUNDREDTHS_MM / 100}mm." + ) + if well_max_volume_ul > MAX_VOLUME_TENTHS_UL / 10: + raise ValueError( + f"Well max volume {well_max_volume_ul} uL exceeds Multidrop limit of " + f"{MAX_VOLUME_TENTHS_UL / 10} uL." + ) + + return { + "column_positions": columns, + "row_positions": rows, + "rows": rows, + "columns": columns, + "height": height_hundredths, + "max_volume": well_max_volume_ul, + } + + +def plate_well_count(plate: Plate) -> int: + """Return the total well count for a plate.""" + return plate.num_items diff --git a/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py b/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py new file mode 100644 index 00000000000..c75f15b13a2 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py @@ -0,0 +1,35 @@ +"""Thermo Scientific Multidrop Combi device.""" + +from __future__ import annotations + +from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensing8 +from pylabrobot.device import Device +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8 import ( + MultidropCombiPeristalticDispensingBackend8, +) + + +class MultidropCombi(Device): + """Thermo Scientific Multidrop Combi reagent dispenser. + + Args: + port: Serial port (e.g. "COM3", "/dev/ttyUSB0"). + timeout: Default serial read timeout in seconds. + """ + + def __init__( + self, + port: str, + timeout: float = 30.0, + *, + driver: MultidropCombiDriver | None = None, + ) -> None: + if driver is None: + driver = MultidropCombiDriver(port=port, timeout=timeout) + super().__init__(driver=driver) + self.driver: MultidropCombiDriver = driver + self.peristaltic_dispenser = PeristalticDispensing8( + backend=MultidropCombiPeristalticDispensingBackend8(driver) + ) + self._capabilities = [self.peristaltic_dispenser] diff --git a/pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend8.py b/pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend8.py new file mode 100644 index 00000000000..715b8cd5d86 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend8.py @@ -0,0 +1,356 @@ +"""Peristaltic dispensing capability backend for the Multidrop Combi. + +All volume parameters at the public interface are in microliters (float). +Internally, volumes are converted to the instrument's native 1/10 uL units. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Dict, Optional + +from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensingBackend8 +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Plate +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.enums import ( + DispensingOrder, + EmptyMode, + PrimeMode, +) + +logger = logging.getLogger(__name__) + + +def _ul_to_tenths(volume_ul: float) -> int: + """Convert microliters to 1/10 uL integer.""" + return round(volume_ul * 10) + + +class MultidropCombiPeristalticDispensingBackend8(PeristalticDispensingBackend8): + """Translates PeristalticDispensingBackend8 operations into Multidrop Combi commands.""" + + def __init__(self, driver: MultidropCombiDriver): + super().__init__() + self._driver = driver + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + """Clear any pending instrument errors after the driver connects.""" + try: + await self._driver.acknowledge_error() + except Exception: + pass + + @dataclass + class DispenseParams(BackendParams): + """Parameters for the Multidrop Combi dispense command. + + Parameters are sent in the order recommended by the instrument workflow: + plate_type → cassette_type → pump_speed → dispensing_height → volumes → dispense. + + Args: + plate_type: Plate type index (0-29). If None, uses current setting. + cassette_type: Cassette type (0=Standard, 1=Small, 2-3=User-defined). If None, uses current. + pump_speed: Speed percentage (1-100). If None, uses current setting. + dispensing_height: Height in 1/100 mm (500-5500). If None, uses current setting. + dispensing_order: Well traversal order for 384+ well plates (no effect on 96-well). + ROW_WISE fills across columns within each row (A1→A2→A3→...→B1→B2→...), + COLUMN_WISE fills down rows within each column (A1→B1→...→H1→A2→B2→...). + Does not affect per-column volumes. If None, uses current. + """ + + plate_type: Optional[int] = None + cassette_type: Optional[int] = None + pump_speed: Optional[int] = None + dispensing_height: Optional[int] = None + dispensing_order: Optional[DispensingOrder] = None + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid to the plate (DIS command). + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + backend_params: A DispenseParams instance with device-specific settings. + """ + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + + # Follow the instrument workflow order: + # set_plate_type → set_cassette_type → set_pump_speed → set_dispensing_height → set_volumes + if backend_params.plate_type is not None: + await self._set_plate_type(backend_params.plate_type) + if backend_params.cassette_type is not None: + await self._set_cassette_type(backend_params.cassette_type) + if backend_params.pump_speed is not None: + await self._set_pump_speed(backend_params.pump_speed) + if backend_params.dispensing_height is not None: + await self._set_dispensing_height(backend_params.dispensing_height) + if backend_params.dispensing_order is not None: + await self._set_dispensing_order(backend_params.dispensing_order) + for col, vol in volumes.items(): + await self._set_column_volume(col, vol) + + vol_min = min(volumes.values()) + vol_max = max(volumes.values()) + logger.info( + "[Multidrop %s] dispense: plate=%s, columns=%d, volume_range=%.1f-%.1f uL", + self._driver._port, + plate.name, + len(volumes), + vol_min, + vol_max, + ) + await self._driver.send_command("DIS", timeout=120.0) + + @dataclass + class PrimeParams(BackendParams): + """Parameters for the Multidrop Combi prime command. + + Args: + mode: Prime mode (standard, continuous, stop continuous, calibration). + """ + + mode: PrimeMode = PrimeMode.STANDARD + + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime dispenser hoses (PRI command). + + The Multidrop Combi only supports volume-based priming, not duration. + + Args: + plate: Target plate. + volume: Prime volume in microliters. + duration: Not supported — raises ValueError if provided. + backend_params: A PrimeParams instance with device-specific settings. + """ + if duration is not None: + raise ValueError("Multidrop Combi does not support duration-based priming. Use volume.") + if volume is None: + raise ValueError("volume is required for Multidrop Combi priming.") + + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Prime volume must be 1-10000 uL, got {volume} uL") + logger.info( + "[Multidrop %s] prime: volume=%.1f uL, mode=%s", + self._driver._port, + volume, + backend_params.mode.name, + ) + cmd = f"PRI {vol_tenths}" + if backend_params.mode != PrimeMode.STANDARD: + cmd += f" {backend_params.mode.value}" + await self._driver.send_command(cmd, timeout=60.0 + volume / 100.0) + + @dataclass + class PurgeParams(BackendParams): + """Parameters for the Multidrop Combi purge (empty) command. + + Args: + mode: Empty mode (standard or continuous). + """ + + mode: EmptyMode = EmptyMode.STANDARD + + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Purge (empty) dispenser hoses (EMP command). + + The Multidrop Combi only supports volume-based purging, not duration. + + Args: + plate: Target plate. + volume: Purge volume in microliters. + duration: Not supported — raises ValueError if provided. + backend_params: A PurgeParams instance with device-specific settings. + """ + if duration is not None: + raise ValueError("Multidrop Combi does not support duration-based purging. Use volume.") + if volume is None: + raise ValueError("volume is required for Multidrop Combi purging.") + + if not isinstance(backend_params, self.PurgeParams): + backend_params = self.PurgeParams() + + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Purge volume must be 1-10000 uL, got {volume} uL") + logger.info( + "[Multidrop %s] purge: volume=%.1f uL, mode=%s", + self._driver._port, + volume, + backend_params.mode.name, + ) + cmd = f"EMP {vol_tenths}" + if backend_params.mode != EmptyMode.STANDARD: + cmd += f" {backend_params.mode.value}" + await self._driver.send_command(cmd, timeout=60.0 + volume / 100.0) + + # --- Multidrop-specific methods --- + + async def shake(self, time: float, distance: int, speed: int) -> None: + """Shake the plate. + + Args: + time: Duration in seconds. + distance: Shake distance in mm (1-5). + speed: Shake frequency in Hz (1-20). + """ + if not 1 <= distance <= 5: + raise ValueError(f"Shake distance must be 1-5 mm, got {distance}") + if not 1 <= speed <= 20: + raise ValueError(f"Shake speed must be 1-20 Hz, got {speed}") + time_hundredths = round(time * 100) + if time_hundredths < 1: + raise ValueError(f"Shake time must be > 0, got {time}s") + logger.info( + "[Multidrop %s] shake: duration=%.1f s, distance=%d mm, speed=%d Hz", + self._driver._port, + time, + distance, + speed, + ) + await self._driver.send_command( + f"SHA {time_hundredths} {distance} {speed}", timeout=120.0 + time + ) + + async def move_plate_out(self) -> None: + """Move plate carrier to loading position (POU command).""" + await self._driver.send_command("POU", timeout=10.0) + + async def set_cassette_type(self, cassette_type: int) -> None: + """Set cassette type. + + Args: + cassette_type: Cassette type (0=Standard, 1=Small, 2-3=User-defined). + """ + if not 0 <= cassette_type <= 3: + raise ValueError(f"Cassette type must be 0-3, got {cassette_type}") + await self._driver.send_command(f"SCT {cassette_type}", timeout=5.0) + + async def abort(self) -> None: + """Abort the current operation.""" + logger.info("[Multidrop %s] abort", self._driver._port) + await self._driver.send_abort_signal() + + async def set_dispense_offset(self, x_offset: int, y_offset: int) -> None: + """Set X/Y dispense offset. + + Args: + x_offset: X offset in 1/100 mm (+-300). + y_offset: Y offset in 1/100 mm (+-300). + """ + if not -300 <= x_offset <= 300: + raise ValueError(f"X offset must be +-300, got {x_offset}") + if not -300 <= y_offset <= 300: + raise ValueError(f"Y offset must be +-300, got {y_offset}") + await self._driver.send_command(f"SOF {x_offset} {y_offset}", timeout=5.0) + + async def set_predispense_volume(self, volume: float) -> None: + """Set predispense volume. + + Args: + volume: Predispense volume in microliters. + """ + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Predispense volume must be 1-10000 uL, got {volume} uL") + await self._driver.send_command(f"SPV {vol_tenths}", timeout=5.0) + + async def define_plate( + self, + column_positions: int, + row_positions: int, + rows: int, + columns: int, + height: int, + max_volume: float, + x_offset: int = 0, + y_offset: int = 0, + ) -> None: + """Define a remote plate (PLA command). + + Args: + column_positions: Number of column positions. + row_positions: Number of row positions. + rows: Number of rows. + columns: Number of columns. + height: Plate height in 1/100 mm. + max_volume: Maximum well volume in microliters. + x_offset: X offset in 1/100 mm. + y_offset: Y offset in 1/100 mm. + """ + max_volume_tenths = _ul_to_tenths(max_volume) + await self._driver.send_command( + f"PLA {column_positions} {row_positions} {rows} {columns} " + f"{height} {max_volume_tenths} {x_offset} {y_offset}", + timeout=5.0, + ) + + async def start_protocol( + self, plate_type: int | None = None, protocol_name: str | None = None + ) -> None: + """Start a protocol from instrument memory (BGN command). + + Args: + plate_type: Optional plate type override. + protocol_name: Optional protocol name. + """ + cmd = "BGN" + if plate_type is not None: + cmd += f" {plate_type}" + if protocol_name is not None: + cmd += f" {protocol_name}" + await self._driver.send_command(cmd, timeout=120.0) + + # --- Private configuration methods (called by dispense) --- + + async def _set_plate_type(self, plate_type: int) -> None: + if not 0 <= plate_type <= 29: + raise ValueError(f"Plate type must be 0-29, got {plate_type}") + await self._driver.send_command(f"SPL {plate_type}", timeout=5.0) + + async def _set_cassette_type(self, cassette_type: int) -> None: + if not 0 <= cassette_type <= 3: + raise ValueError(f"Cassette type must be 0-3, got {cassette_type}") + await self._driver.send_command(f"SCT {cassette_type}", timeout=5.0) + + async def _set_column_volume(self, column: int, volume: float) -> None: + if not 1 <= column <= 48: + raise ValueError(f"Column must be 1-48, got {column}") + vol_tenths = _ul_to_tenths(volume) + await self._driver.send_command(f"SCV {column} {vol_tenths}", timeout=5.0) + + async def _set_dispensing_height(self, height: int) -> None: + if not 500 <= height <= 5500: + raise ValueError(f"Dispensing height must be 500-5500, got {height}") + await self._driver.send_command(f"SDH {height}", timeout=5.0) + + async def _set_pump_speed(self, speed: int) -> None: + if not 1 <= speed <= 100: + raise ValueError(f"Pump speed must be 1-100, got {speed}") + await self._driver.send_command(f"SPS {speed}", timeout=5.0) + + async def _set_dispensing_order(self, order: DispensingOrder) -> None: + await self._driver.send_command(f"SDO {int(order)}", timeout=5.0) diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/__init__.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py new file mode 100644 index 00000000000..d125310da6c --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py @@ -0,0 +1,79 @@ +import contextlib +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver + + +class DriverSerializationTests(unittest.TestCase): + def test_serialize(self): + driver = MultidropCombiDriver(port="COM3", timeout=15.0) + data = driver.serialize() + self.assertEqual(data["type"], "MultidropCombiDriver") + self.assertEqual(data["port"], "COM3") + self.assertEqual(data["timeout"], 15.0) + + def test_serialize_defaults(self): + driver = MultidropCombiDriver(port="/dev/ttyUSB0") + data = driver.serialize() + self.assertEqual(data["port"], "/dev/ttyUSB0") + self.assertEqual(data["timeout"], 30.0) + + +class DriverLifecycleTests(unittest.IsolatedAsyncioTestCase): + @patch("pylabrobot.thermo_fisher.multidrop_combi.driver.Serial") + async def test_setup_and_stop(self, MockSerial): + mock_serial = MagicMock() + mock_serial.setup = AsyncMock() + mock_serial.stop = AsyncMock() + mock_serial.write = AsyncMock() + mock_serial.readline = AsyncMock() + mock_serial.reset_input_buffer = AsyncMock() + mock_serial.reset_output_buffer = AsyncMock() + MockSerial.return_value = mock_serial + + # Mock timeout API + _timeout = 30.0 + + def get_read_timeout(): + return _timeout + + def set_read_timeout(t): + nonlocal _timeout + _timeout = t + + @contextlib.contextmanager + def temporary_timeout(t): + original = get_read_timeout() + set_read_timeout(t) + try: + yield + finally: + set_read_timeout(original) + + mock_serial.get_read_timeout = get_read_timeout + mock_serial.set_read_timeout = set_read_timeout + mock_serial.temporary_timeout = temporary_timeout + + # Setup readline responses: drain (empty), VER + mock_serial.readline.side_effect = [ + b"", # drain - empty + b"VER\r\n", # VER echo + b"MultidropCombi 2.00.29 836-4191\r\n", # VER data + b"VER END 0\r\n", # VER end + ] + + driver = MultidropCombiDriver(port="COM3") + await driver.setup() + + self.assertEqual(driver._instrument_name, "MultidropCombi") + self.assertEqual(driver._firmware_version, "2.00.29") + self.assertEqual(driver._serial_number, "836-4191") + + # Reset readline for QIT during stop + mock_serial.readline.side_effect = [ + b"QIT\r\n", + b"QIT END 0\r\n", + ] + await driver.stop() + mock_serial.stop.assert_awaited_once() diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py new file mode 100644 index 00000000000..61c493cde22 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py @@ -0,0 +1,305 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.resources import Plate, Well, create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, WellBottomType +from pylabrobot.thermo_fisher.multidrop_combi.enums import DispensingOrder, EmptyMode, PrimeMode +from pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend8 import ( + MultidropCombiPeristalticDispensingBackend8, + _ul_to_tenths, +) + + +def _make_backend() -> MultidropCombiPeristalticDispensingBackend8: + """Create a backend with a mock driver.""" + driver = MagicMock() + driver.send_command = AsyncMock(return_value=[]) + driver.send_abort_signal = AsyncMock() + driver.acknowledge_error = AsyncMock() + backend = MultidropCombiPeristalticDispensingBackend8(driver=driver) + return backend + + +def _make_plate() -> Plate: + return Plate( + name="test_plate", + size_x=127.76, + size_y=85.48, + size_z=14.2, + model="test", + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.0, + dy=7.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + cross_section_type=CrossSectionType.CIRCLE, + max_volume=360.0, + ), + ) + + +class VolumeConversionTests(unittest.TestCase): + def test_ul_to_tenths(self): + self.assertEqual(_ul_to_tenths(1.0), 10) + self.assertEqual(_ul_to_tenths(50.0), 500) + self.assertEqual(_ul_to_tenths(0.1), 1) + self.assertEqual(_ul_to_tenths(10000.0), 100000) + + def test_ul_to_tenths_rounding(self): + self.assertEqual(_ul_to_tenths(1.06), 11) + self.assertEqual(_ul_to_tenths(1.04), 10) + + +class DispenseTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_dispense_bare(self): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] # type: ignore[attr-defined] + self.assertEqual(calls, ["SCV 1 100", "DIS"]) + + async def test_dispense_per_column(self): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0, 3: 20.0}) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] # type: ignore[attr-defined] + self.assertEqual(calls, ["SCV 1 100", "SCV 3 200", "DIS"]) + + async def test_dispense_with_all_params(self): + params = MultidropCombiPeristalticDispensingBackend8.DispenseParams( + plate_type=3, + cassette_type=0, + pump_speed=75, + dispensing_height=2500, + dispensing_order=DispensingOrder.COLUMN_WISE, + ) + await self.backend.dispense(plate=self.plate, volumes={1: 50.0}, backend_params=params) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] # type: ignore[attr-defined] + # Order: plate_type → cassette_type → pump_speed → dispensing_height → dispensing_order → volumes → DIS + self.assertEqual(calls, ["SPL 3", "SCT 0", "SPS 75", "SDH 2500", "SDO 1", "SCV 1 500", "DIS"]) + + async def test_dispense_order(self): + params = MultidropCombiPeristalticDispensingBackend8.DispenseParams( + dispensing_order=DispensingOrder.ROW_WISE, + ) + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] # type: ignore[attr-defined] + self.assertEqual(calls, ["SDO 0", "SCV 1 100", "DIS"]) + + +class PrimeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_prime_standard(self): + await self.backend.prime(plate=self.plate, volume=50.0) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "PRI 500") + + async def test_prime_continuous(self): + params = MultidropCombiPeristalticDispensingBackend8.PrimeParams(mode=PrimeMode.CONTINUOUS) + await self.backend.prime(plate=self.plate, volume=50.0, backend_params=params) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "PRI 500 1") + + async def test_prime_duration_not_supported(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate, duration=10) + + async def test_prime_volume_required(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate) + + +class PurgeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_purge_standard(self): + await self.backend.purge(plate=self.plate, volume=100.0) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "EMP 1000") + + async def test_purge_continuous(self): + params = MultidropCombiPeristalticDispensingBackend8.PurgeParams(mode=EmptyMode.CONTINUOUS) + await self.backend.purge(plate=self.plate, volume=100.0, backend_params=params) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "EMP 1000 1") + + async def test_purge_duration_not_supported(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate, duration=10) + + async def test_purge_volume_required(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate) + + +class ShakeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + + async def test_shake(self): + await self.backend.shake(time=5.0, distance=3, speed=10) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "SHA 500 3 10") + + +class DeviceSpecificTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + + async def test_move_plate_out(self): + await self.backend.move_plate_out() + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "POU") + + async def test_set_cassette_type(self): + await self.backend.set_cassette_type(cassette_type=1) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "SCT 1") + + async def test_abort(self): + await self.backend.abort() + self.backend._driver.send_abort_signal.assert_awaited_once() # type: ignore[attr-defined] + + async def test_set_dispense_offset(self): + await self.backend.set_dispense_offset(x_offset=100, y_offset=-50) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "SOF 100 -50") + + async def test_set_predispense_volume(self): + await self.backend.set_predispense_volume(volume=10.0) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "SPV 100") + + async def test_define_plate(self): + await self.backend.define_plate( + column_positions=12, + row_positions=8, + rows=8, + columns=12, + height=1420, + max_volume=360.0, + ) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "PLA 12 8 8 12 1420 3600 0 0") + + async def test_define_plate_with_offsets(self): + await self.backend.define_plate( + column_positions=12, + row_positions=8, + rows=8, + columns=12, + height=1420, + max_volume=360.0, + x_offset=100, + y_offset=-50, + ) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "PLA 12 8 8 12 1420 3600 100 -50") + + async def test_start_protocol_bare(self): + await self.backend.start_protocol() + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "BGN") + + async def test_start_protocol_with_plate_type(self): + await self.backend.start_protocol(plate_type=3) + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "BGN 3") + + async def test_start_protocol_with_name(self): + await self.backend.start_protocol(plate_type=3, protocol_name="MyProtocol") + args = self.backend._driver.send_command.call_args # type: ignore[attr-defined] + self.assertEqual(args[0][0], "BGN 3 MyProtocol") + + +class ParameterValidationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_prime_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate, volume=0.0) + + async def test_prime_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate, volume=20000.0) + + async def test_purge_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate, volume=0.0) + + async def test_shake_distance_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=0, speed=10) + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=6, speed=10) + + async def test_shake_speed_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=3, speed=0) + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=3, speed=21) + + async def test_dispense_plate_type_out_of_range(self): + params = MultidropCombiPeristalticDispensingBackend8.DispenseParams(plate_type=-1) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + params = MultidropCombiPeristalticDispensingBackend8.DispenseParams(plate_type=30) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + + async def test_dispense_column_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={0: 10.0}) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={49: 10.0}) + + async def test_dispense_height_out_of_range(self): + params = MultidropCombiPeristalticDispensingBackend8.DispenseParams(dispensing_height=499) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + + async def test_dispense_pump_speed_out_of_range(self): + params = MultidropCombiPeristalticDispensingBackend8.DispenseParams(pump_speed=0) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + + async def test_cassette_type_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_cassette_type(cassette_type=4) + + async def test_dispense_offset_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_dispense_offset(x_offset=301, y_offset=0) + with self.assertRaises(ValueError): + await self.backend.set_dispense_offset(x_offset=0, y_offset=-301) + + async def test_purge_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate, volume=20000.0) + + async def test_predispense_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.set_predispense_volume(volume=0.0) + + async def test_predispense_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.set_predispense_volume(volume=20000.0) + + async def test_on_setup_calls_acknowledge_error(self): + await self.backend._on_setup() + self.backend._driver.acknowledge_error.assert_awaited_once() # type: ignore[attr-defined] diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py new file mode 100644 index 00000000000..c26b01bc4f0 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py @@ -0,0 +1,182 @@ +import asyncio +import contextlib +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiInstrumentError, +) + + +def _make_driver() -> MultidropCombiDriver: + """Create a driver with a mock Serial for testing.""" + driver = MultidropCombiDriver(port="COM3") + mock_io = MagicMock() + mock_io.write = AsyncMock() + mock_io.readline = AsyncMock() + mock_io.reset_input_buffer = AsyncMock() + mock_io.reset_output_buffer = AsyncMock() + + # Mock the timeout API + _timeout = 30.0 + + def get_read_timeout(): + return _timeout + + def set_read_timeout(t): + nonlocal _timeout + _timeout = t + + mock_io.get_read_timeout = get_read_timeout + mock_io.set_read_timeout = set_read_timeout + mock_io.temporary_timeout = lambda t: contextlib.contextmanager( + lambda: (mock_io.set_read_timeout(t), (yield), mock_io.set_read_timeout(_timeout)) # type: ignore[arg-type, misc] + )() + + # Use the real temporary_timeout from Serial for correct behavior + @contextlib.contextmanager + def _temporary_timeout(timeout): + original = mock_io.get_read_timeout() + mock_io.set_read_timeout(timeout) + try: + yield + finally: + mock_io.set_read_timeout(original) + + mock_io.temporary_timeout = _temporary_timeout + + driver.io = mock_io + driver._command_lock = asyncio.Lock() + return driver + + +class SendCommandTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.driver = _make_driver() + + async def test_simple_command(self) -> None: + """Test a simple command with echo + END response.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"SPL\r\n", + b"SPL END 0\r\n", + ] + result = await self.driver.send_command("SPL 1") + self.assertEqual(result, []) + self.driver.io.write.assert_awaited_once_with(b"SPL 1\r") # type: ignore[attr-defined] + + async def test_command_with_data_lines(self) -> None: + """Test a command that returns data lines between echo and END.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + result = await self.driver.send_command("VER") + self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) + + async def test_command_with_error_status(self) -> None: + """Test that non-zero status raises MultidropCombiInstrumentError.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"SPL\r\n", + b"SPL END 18\r\n", + ] + with self.assertRaises(MultidropCombiInstrumentError) as ctx: + await self.driver.send_command("SPL 99") + self.assertEqual(ctx.exception.status_code, 18) + self.assertIn("Invalid plate type", ctx.exception.description) + + async def test_timeout_raises_communication_error(self) -> None: + """Test that timeout (empty readline) raises MultidropCombiCommunicationError.""" + self.driver.io.readline.side_effect = [b""] # type: ignore[attr-defined] + with self.assertRaises(MultidropCombiCommunicationError): + await self.driver.send_command("SPL 1") + + async def test_not_connected(self) -> None: + """Test that sending a command when not set up raises error.""" + self.driver._command_lock = None + with self.assertRaises(MultidropCombiCommunicationError): + await self.driver.send_command("VER") + + async def test_custom_timeout(self) -> None: + """Test that custom timeout is set during command and restored after.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"POU\r\n", + b"POU END 0\r\n", + ] + original = self.driver.io.get_read_timeout() + await self.driver.send_command("POU", timeout=10.0) + self.assertEqual(self.driver.io.get_read_timeout(), original) + + async def test_echo_skipping_case_insensitive(self) -> None: + """Test that echo is skipped regardless of case.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"ver\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + result = await self.driver.send_command("VER") + self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) + + +class EnterRemoteModeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.driver = _make_driver() + + async def test_enter_remote_mode_success(self) -> None: + """Test successful VER command parses instrument info.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + info = await self.driver._enter_remote_mode() + self.assertEqual(info["instrument_name"], "MultidropCombi") + self.assertEqual(info["firmware_version"], "2.00.29") + self.assertEqual(info["serial_number"], "836-4191") + + async def test_enter_remote_mode_retry_after_eak(self) -> None: + """Test VER retry after EAK when first VER fails.""" + call_count = 0 + + async def readline_side_effect() -> bytes: + nonlocal call_count + call_count += 1 + responses = [ + b"VER\r\n", + b"VER END 1\r\n", + b"EAK\r\n", + b"EAK END 0\r\n", + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + if call_count <= len(responses): + return responses[call_count - 1] + return b"" + + self.driver.io.readline.side_effect = readline_side_effect # type: ignore[attr-defined] + info = await self.driver._enter_remote_mode() + self.assertEqual(info["instrument_name"], "MultidropCombi") + + +class DrainStaleDataTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.driver = _make_driver() + + async def test_drain_with_stale_data(self) -> None: + """Test draining stale data from buffer.""" + self.driver.io.readline.side_effect = [ # type: ignore[attr-defined] + b"stale line 1\r\n", + b"stale line 2\r\n", + b"", + ] + await self.driver._drain_stale_data() + self.driver.io.reset_input_buffer.assert_awaited_once() # type: ignore[attr-defined] + self.driver.io.reset_output_buffer.assert_awaited_once() # type: ignore[attr-defined] + + async def test_drain_empty_buffer(self) -> None: + """Test draining when buffer is already empty.""" + self.driver.io.readline.side_effect = [b""] # type: ignore[attr-defined] + await self.driver._drain_stale_data() diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py new file mode 100644 index 00000000000..510850e1a61 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py @@ -0,0 +1,209 @@ +import unittest + +from pylabrobot.resources import Plate, Well, create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, WellBottomType +from pylabrobot.thermo_fisher.multidrop_combi.helpers import ( + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) + + +def _make_plate( + num_items_x: int = 12, + num_items_y: int = 8, + size_z: float = 14.2, + well_max_volume: float = 360.0, + well_size_z: float = 10.67, +) -> Plate: + """Create a test plate with the given parameters.""" + return Plate( + name="test_plate", + size_x=127.76, + size_y=85.48, + size_z=size_z, + model="test", + ordered_items=create_ordered_items_2d( + Well, + num_items_x=num_items_x, + num_items_y=num_items_y, + dx=10.0, + dy=7.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=well_size_z, + bottom_type=WellBottomType.FLAT, + cross_section_type=CrossSectionType.CIRCLE, + max_volume=well_max_volume, + ), + ) + + +class PlateToTypeIndexTests(unittest.TestCase): + """Test factory plate type mapping.""" + + def test_96_well_short(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.0) + self.assertEqual(plate_to_type_index(plate), 0) # 15mm type + + def test_96_well_medium(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=20.0) + self.assertEqual(plate_to_type_index(plate), 1) # 22mm type + + def test_96_well_tall(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=40.0) + self.assertEqual(plate_to_type_index(plate), 2) # 44mm type + + def test_384_well_very_short(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=7.0) + self.assertEqual(plate_to_type_index(plate), 3) # 7.5mm type + + def test_384_well_short(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.0) + self.assertEqual(plate_to_type_index(plate), 4) # 10mm type + + def test_384_well_medium(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=14.0) + self.assertEqual(plate_to_type_index(plate), 5) # 15mm type + + def test_384_well_tall(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=25.0) + self.assertEqual(plate_to_type_index(plate), 6) # 22mm type + + def test_384_well_very_tall(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=44.0) + self.assertEqual(plate_to_type_index(plate), 7) # 44mm type + + def test_1536_well_short(self): + plate = _make_plate(num_items_x=48, num_items_y=32, size_z=5.0) + self.assertEqual(plate_to_type_index(plate), 8) # 5mm type + + def test_1536_well_tall(self): + plate = _make_plate(num_items_x=48, num_items_y=32, size_z=10.0) + self.assertEqual(plate_to_type_index(plate), 9) # 10.5mm type + + def test_unsupported_well_count(self): + plate = _make_plate(num_items_x=6, num_items_y=4) # 24-well + with self.assertRaises(ValueError) as ctx: + plate_to_type_index(plate) + self.assertIn("24", str(ctx.exception)) + + def test_96_well_too_tall(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=60.0) + with self.assertRaises(ValueError): + plate_to_type_index(plate) + + +class PlateToTypeIndexRealPlatesTests(unittest.TestCase): + """Test with real PLR plate definitions.""" + + def test_corning_96_well(self): + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + plate = Cor_96_wellplate_360ul_Fb("test") + self.assertEqual(plate_to_type_index(plate), 0) # 14.2mm → type 0 + + def test_biorad_384_well(self): + from pylabrobot.resources.biorad.plates import BioRad_384_wellplate_50uL_Vb + + plate = BioRad_384_wellplate_50uL_Vb("test") + self.assertEqual(plate_to_type_index(plate), 4) # 10.4mm → type 4 + + +class PlateToPlaParamsTests(unittest.TestCase): + """Test PLA command parameter generation.""" + + def test_96_well_params(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.2, well_max_volume=360.0) + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 12) + self.assertEqual(params["rows"], 8) + self.assertEqual(params["column_positions"], 12) + self.assertEqual(params["row_positions"], 8) + self.assertEqual(params["height"], 1420) # 14.2mm * 100 + self.assertEqual(params["max_volume"], 360.0) + + def test_384_well_params(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.4, well_max_volume=50.0) + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 24) + self.assertEqual(params["rows"], 16) + self.assertEqual(params["height"], 1040) + self.assertEqual(params["max_volume"], 50.0) + + def test_real_corning_96_well(self): + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + plate = Cor_96_wellplate_360ul_Fb("test") + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 12) + self.assertEqual(params["rows"], 8) + self.assertEqual(params["height"], 1420) + self.assertEqual(params["max_volume"], 360.0) + + +class PlaParamsValidationTests(unittest.TestCase): + """Test parameter validation in plate_to_pla_params.""" + + def test_too_many_columns(self): + plate = _make_plate(num_items_x=49, num_items_y=8, size_z=14.0) + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("49 columns", str(ctx.exception)) + self.assertIn("48", str(ctx.exception)) + + def test_too_many_rows(self): + plate = _make_plate(num_items_x=12, num_items_y=33, size_z=14.0) + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("33 rows", str(ctx.exception)) + self.assertIn("32", str(ctx.exception)) + + def test_height_too_low(self): + plate = _make_plate(size_z=4.0) # 4mm < 5mm minimum + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("4.0mm", str(ctx.exception)) + self.assertIn("minimum", str(ctx.exception)) + + def test_height_too_high(self): + plate = _make_plate(size_z=60.0) # 60mm > 55mm maximum + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("60.0mm", str(ctx.exception)) + self.assertIn("maximum", str(ctx.exception)) + + def test_well_volume_too_high(self): + plate = _make_plate(well_max_volume=3000.0) # 3000uL > 2500uL max + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("3000", str(ctx.exception)) + self.assertIn("2500", str(ctx.exception)) + + def test_height_at_minimum_boundary(self): + plate = _make_plate(size_z=5.0) # exactly 5mm = 500 hundredths + params = plate_to_pla_params(plate) + self.assertEqual(params["height"], 500) + + def test_height_at_maximum_boundary(self): + plate = _make_plate(size_z=55.0) # exactly 55mm = 5500 hundredths + params = plate_to_pla_params(plate) + self.assertEqual(params["height"], 5500) + + def test_volume_at_maximum_boundary(self): + plate = _make_plate(well_max_volume=2500.0) # exactly 2500uL + params = plate_to_pla_params(plate) + self.assertEqual(params["max_volume"], 2500.0) + + +class PlateWellCountTests(unittest.TestCase): + def test_96_well(self): + plate = _make_plate(num_items_x=12, num_items_y=8) + self.assertEqual(plate_well_count(plate), 96) + + def test_384_well(self): + plate = _make_plate(num_items_x=24, num_items_y=16) + self.assertEqual(plate_well_count(plate), 384) 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/tilting/chatterbox.py b/pylabrobot/tilting/chatterbox.py deleted file mode 100644 index 5ef3ea93be0..00000000000 --- a/pylabrobot/tilting/chatterbox.py +++ /dev/null @@ -1,12 +0,0 @@ -from pylabrobot.tilting import TilterBackend - - -class TilterChatterboxBackend(TilterBackend): - async def setup(self): - print("Setting up tilter.") - - async def stop(self): - print("Stopping tilter.") - - async def set_angle(self, angle: float): - print(f"Setting the angle to {angle}.") diff --git a/pylabrobot/tilting/hamilton.py b/pylabrobot/tilting/hamilton.py deleted file mode 100644 index d4233407c7b..00000000000 --- a/pylabrobot/tilting/hamilton.py +++ /dev/null @@ -1,46 +0,0 @@ -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.tilting.hamilton_backend import ( - HamiltonTiltModuleBackend, -) -from pylabrobot.tilting.tilter import Tilter - - -class HamiltonTiltModule(Tilter): - """A Hamilton tilt module.""" - - def __init__( - self, - name: str, - com_port: str, - child_location: Coordinate = Coordinate(1.0, 3.0, 83.55), - pedestal_size_z: float = 3.47, - write_timeout: float = 3, - timeout: float = 3, - ): - """Initialize a Hamilton tilt module. - - Args: - com_port: The communication port. - child_location: The location of the child resource. - pedestal_size_z: The size of the pedestal in the z dimension. - write_timeout: The write timeout. Defaults to 3. - timeout: The timeout. Defaults to 3. - """ - - super().__init__( - name=name, - size_x=132, - size_y=92.57, - size_z=85.81, - backend=HamiltonTiltModuleBackend( - com_port=com_port, - write_timeout=write_timeout, - timeout=timeout, - ), - hinge_coordinate=Coordinate(6.18, 0, 72.85), - child_location=child_location, - category="tilter", - model=HamiltonTiltModule.__name__, - ) - - self.pedestal_size_z = pedestal_size_z diff --git a/pylabrobot/tilting/hamilton_backend.py b/pylabrobot/tilting/hamilton_backend.py deleted file mode 100644 index 34ea0337d8b..00000000000 --- a/pylabrobot/tilting/hamilton_backend.py +++ /dev/null @@ -1,313 +0,0 @@ -import re -from typing import Optional - -try: - import serial - - HAS_SERIAL = True -except ImportError as e: - HAS_SERIAL = False - _SERIAL_IMPORT_ERROR = e - -from pylabrobot.io.serial import Serial -from pylabrobot.tilting.tilter_backend import ( - TilterBackend, - TiltModuleError, -) - - -class HamiltonTiltModuleBackend(TilterBackend): - """Backend for the Hamilton tilt module.""" - - def __init__( - self, - com_port: str, - write_timeout: float = 10, - timeout: float = 10, - ): - if not HAS_SERIAL: - raise RuntimeError( - f"pyserial is required for the Hamilton tilt module backend. Import error: {_SERIAL_IMPORT_ERROR}" - ) - - self.com_port = com_port - self.io = Serial( - port=self.com_port, - baudrate=1200, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_EVEN, - stopbits=serial.STOPBITS_ONE, - write_timeout=write_timeout, - timeout=timeout, - human_readable_device_name="Hamilton Tilt Module", - ) - - async def setup(self, initial_offset: int = 0): - await self.io.setup() - await self.tilt_initial_offset(initial_offset) - await self.tilt_initialize() - - async def stop(self): - await self.io.stop() - - async def send_command(self, command: str, parameter: Optional[str] = None) -> str: - """Send a command to the tilt module.""" - - if parameter is None: - parameter = "" - - await self.io.write(f"99{command}{parameter}\r\n".encode("utf-8")) - resp = "" - while not resp.startswith("T1" + command): - resp = (await self.io.read(128)).decode("utf-8") - - # Check for error. - error_matches = re.search("er[0-9]{2}", resp) - if error_matches is not None: - err_code = int(error_matches.group(0)[2:]) - if 1 <= err_code <= 7: - raise TiltModuleError( - { - 1: "Init Position not found", - 2: "**Step** loss", - 3: "Not initialized", - 5: "Stepper Motor end stage defective", - 6: "Parameter out **of** Range", - 7: "Undefined Command", - }[err_code] - ) - if err_code != 0: - raise RuntimeError(f"Unexpected error code: {err_code}") - - return resp - - async def set_angle(self, angle: float): - """Set the tilt module to rotate by a given angle.""" - - assert 0 <= angle <= 10, "Angle must be between 0 and 10 degrees." - - await self.tilt_go_to_position(round(angle)) - - async def tilt_initialize(self): - """Initialize a daisy chained tilt module.""" - - return await self.send_command("SI") - - async def tilt_move_to_absolute_step_position(self, position: float): - """Move the tilt module to an absolute position. - - Args: - position: absolute position (-10...120) - """ - - assert -10 <= position <= 120, "Position must be between -10 and 120." - - return await self.send_command( - command="SA", - parameter=str(position), - ) - - async def tilt_move_to_relative_step_position(self, steps: float): - """Move the tilt module to a relative position. - - .. warning:: This method has the potential to decalibrate the tilt module. - - Args: - steps: the number of steps (±10000) - """ - - assert -10000 <= steps <= 10000, "Steps must be between -10000 and 10000." - - return await self.send_command(command="SR", parameter=str(steps)) - - async def tilt_go_to_position(self, position: int): - """Go to position (0...10). - - Args: - position: 0 = horizontal, 10 = degrees - """ - - assert 0 <= position <= 10, "Position must be between 0 and 10." - - return await self.send_command(command="GP", parameter=str(position)) - - async def tilt_set_speed(self, speed: int): - """Set the speed on the tilt module. - - Args: - speed: 1 is slow, 9 = fast. Default speed is 1. - """ - - assert 1 <= speed <= 9, "Speed must be between 1 and 9." - - return await self.send_command(command="SV", parameter=str(speed)) - - async def tilt_power_off(self): - """Power off the tilt module.""" - - return await self.send_command(command="PO") - - async def tilt_request_error(self) -> Optional[str]: - """Request the error of the tilt module. - - Returns: the error, if it exists, else `None` - """ - - # send_command will automatically raise an error, if one exists - return await self.send_command("RE") - - async def tilt_request_sensor(self) -> Optional[str]: - """It is unclear what this method does. The documentation lists the following map: - - 0 = LS 1 Input - 1 = LS 2 Input - 2 = LS 3 Input - 3 = PNP Input 1 - 4 = PNP Input 2 - 5 = PNP Input 3 - 6 = NPN Input 1 - 7 = NPN Input 2 - """ - - resp = await self.send_command(command="RX") - resp = resp[:-2].split(" ")[1] - code = int(resp) - - if code == 0: - return None - if 1 <= code <= 7: - return { - 0: "LS 1 Input", - 1: "LS 2 Input", - 2: "LS 3 Input", - 3: "PNP Input 1", - 4: "PNP Input 2", - 5: "PNP Input 3", - 6: "NPN Input 1", - 7: "NPN Input 2", - }[code] - raise RuntimeError(f"Unexpected error code: {code}") - - async def tilt_request_offset_between_light_barrier_and_init_position( - self, - ) -> int: - """Request Offset between Light Barrier and Init Position""" - - resp = await self.send_command(command="RO") - resp = resp[:-2].split(" ")[1] - return int(resp) - - # Open Collectors - - async def tilt_port_set_open_collector(self, open_collector: int): - """Port set open collector - - Args: - open_collector: 1...8 # TODO: what? - """ - - assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" - - return await self.send_command(command="PS", parameter=str(open_collector)) - - async def tilt_port_clear_open_collector(self, open_collector: int): - """Tilt port clear open collector - - Args: - open_collector: 1...8 # TODO: what? - """ - - assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" - - return await self.send_command(command="PC", parameter=str(open_collector)) - - # Single Commands **with** **Option** "Heating": - - async def tilt_set_temperature(self, temperature: float): - """Tilt set the temperature 10.. 50 Grad C [1/10 Grad C] - - Args: - temperature: temperature in Celsisu, between 10 and 50 - """ - - assert 10 <= temperature <= 50, "Temperature must be between 10 and 50." - - return await self.send_command(command="ST", parameter=str(int(temperature * 10))) - - async def tilt_switch_off_temperature_controller(self): - """Switch off the temperature controller on the tilt module.""" - - return await self.send_command( - command="TO", - ) - - # Single Commands **with** **Option** "Waste Pump (PWM2)": - - async def tilt_set_drain_time(self, drain_time: float): - """Set the drain time on the tilt module. - - Args: - drain_time: drain time in seconds, between 5 and 250 - """ - - assert 5 <= drain_time <= 250, "Drain time must be between 5 and 250." - - return await self.send_command(command="DT", parameter=str(int(drain_time * 10))) - - async def tilt_set_waste_pump_on(self): - """Turn the waste pump on the tilt module on""" - - return await self.send_command( - command="WP", - ) - - async def tilt_set_waste_pump_off(self): - """Turn the waste pump on the tilt module off""" - - return await self.send_command( - command="WO", - ) - - # Adjustment Commands: - - async def tilt_set_name(self, name: str): - """Set the tilt module name. - - Args: - name: the desired name, must be 2 characters long - """ - - assert len(name) == 2, "name must be 2 characters long" - - return await self.send_command(command="MN", parameter=name) - - async def tilt_switch_encoder(self, on: bool): - """Switch the encoder on the tilt module on or off. - - Args: - on: if `True`, the encoder will be turned on, else, it will be turned off. - """ - - return await self.send_command(command="EN", parameter=str(int(on))) - - async def tilt_initial_offset(self, offset: int): - """Set the initial offset on the tilt module - - Args: - offset: the initial offset steps, steps between -100 and 100 - """ - - assert -100 <= offset <= 100, "Offset must be between -100 and 100." - - return await self.send_command(command="SO", parameter=str(offset)) - - -class HamiltonTiltModuleChatterboxBackend(HamiltonTiltModuleBackend): - async def setup(self, initial_offset=0): - print(f"[tilter] setup initial offset {initial_offset}") - - async def stop(self): - print("[tilter] stopping") - - async def send_command(self, command, parameter=None): - print(f"[tilter] Sending command: {command} with parameter: {parameter}") diff --git a/pylabrobot/ufactory/xarm6/__init__.py b/pylabrobot/ufactory/xarm6/__init__.py new file mode 100644 index 00000000000..35aca30a6c2 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/__init__.py @@ -0,0 +1,4 @@ +from .backend import * +from .driver import * +from .joints import * +from .xarm6 import * diff --git a/pylabrobot/ufactory/xarm6/backend.py b/pylabrobot/ufactory/xarm6/backend.py new file mode 100644 index 00000000000..02a0f7f2633 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/backend.py @@ -0,0 +1,293 @@ +from dataclasses import dataclass +from typing import List, Optional + +from pylabrobot.capabilities.arms.backend import ( + ArticulatedGripperArmBackend, + CanFreedrive, + HasJoints, +) +from pylabrobot.capabilities.arms.standard import CartesianPose, JointPose +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.ufactory.xarm6.driver import XArm6Driver + + +class XArm6ArmBackend(ArticulatedGripperArmBackend, HasJoints, CanFreedrive): + """Arm capability backend for the UFACTORY xArm 6 with bio-gripper. + + Implements :class:`ArticulatedGripperArmBackend` (full roll/pitch/yaw control) + together with the :class:`HasJoints` and :class:`CanFreedrive` mixins. All + xArm SDK calls are issued directly against ``driver._arm`` via + ``driver._call_sdk``. + + Motion profile (speed / acceleration) is supplied per-call via + :class:`CartesianMoveParams` or :class:`JointMoveParams`; if the caller does + not supply ``backend_params``, the dataclass defaults are used. + + All lengths are millimeters and all angles are degrees. The xArm bio-gripper + uses integer SDK units in the range [0, 850]; gripper widths passed in + millimeters are converted via ``mm_per_gripper_unit`` (default 0.1 mm/unit, + i.e. a fully-open width of 85 mm). + + Args: + driver: :class:`XArm6Driver` instance. + mm_per_gripper_unit: Conversion factor from SDK gripper units to mm. + closed_threshold_mm: Gripper widths at or below this value are considered + closed when :meth:`is_gripper_closed` is called. + park_location: Optional Cartesian location used by :meth:`park`. If + ``None``, :meth:`park` falls back to the xArm SDK home position. + park_rotation: Rotation used together with ``park_location``. + """ + + _MAX_GRIPPER_UNITS = 850 + + @dataclass + class CartesianMoveParams(BackendParams): + """Cartesian motion profile for :meth:`move_to_location`, :meth:`pick_up_at_location`, + and :meth:`drop_at_location`. + + Args: + speed: Cartesian move speed in mm/s. + mvacc: Cartesian move acceleration in mm/s^2. + """ + + speed: float = 100.0 + mvacc: float = 2000.0 + + @dataclass + class JointMoveParams(BackendParams): + """Joint-space motion profile for :meth:`move_to_joint_position`, + :meth:`pick_up_at_joint_position`, and :meth:`drop_at_joint_position`. + + Args: + speed: Joint move speed in deg/s. + mvacc: Joint move acceleration in deg/s^2. + """ + + speed: float = 50.0 + mvacc: float = 500.0 + + def __init__( + self, + driver: XArm6Driver, + 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, + ) -> None: + super().__init__() + self._driver = driver + 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() + + # -- Param coercion -------------------------------------------------------- + + def _cart_params( + self, backend_params: Optional[BackendParams] + ) -> "XArm6ArmBackend.CartesianMoveParams": + if isinstance(backend_params, XArm6ArmBackend.CartesianMoveParams): + return backend_params + return XArm6ArmBackend.CartesianMoveParams() + + def _joint_params( + self, backend_params: Optional[BackendParams] + ) -> "XArm6ArmBackend.JointMoveParams": + if isinstance(backend_params, XArm6ArmBackend.JointMoveParams): + return backend_params + return XArm6ArmBackend.JointMoveParams() + + # -- 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._driver._call_sdk( + self._driver._arm.set_gripper_position, + units, + wait=True, + speed=0, + op="set_gripper_position", + ) + + # -- CanGrip --------------------------------------------------------------- + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the bio-gripper to the specified width (mm).""" + await self._set_gripper_units(self._mm_to_gripper_units(gripper_width)) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Close the bio-gripper to the specified width (mm).""" + await self._set_gripper_units(self._mm_to_gripper_units(gripper_width)) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Return True if the gripper width is at or below ``closed_threshold_mm``.""" + units = await self._driver._call_sdk( + self._driver._arm.get_gripper_position, op="get_gripper_position" + ) + return self._gripper_units_to_mm(int(units)) <= self.closed_threshold_mm + + # -- _BaseArmBackend ------------------------------------------------------- + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Emergency stop all motion.""" + await self._driver._call_sdk(self._driver._arm.emergency_stop, op="emergency_stop") + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Move to ``park_location`` if set, otherwise to the SDK home position. + + If the SDK fails on the first ``move_gohome`` call, the driver clears + errors and retries once automatically. + """ + if self.park_location is not None: + await self.move_to_location(self.park_location, self.park_rotation) + return + await self._driver._call_sdk( + self._driver._arm.move_gohome, + speed=50, + mvacc=5000, + wait=True, + op="move_gohome", + num_retries=1, + ) + + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> CartesianPose: + """Get the current gripper location and rotation.""" + pose = await self._driver._call_sdk(self._driver._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]), + ) + + # -- ArticulatedGripperArmBackend ------------------------------------------ + + async def move_to_location( + self, + location: Coordinate, + rotation: Rotation, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move to a Cartesian location with the given rotation.""" + params = self._cart_params(backend_params) + await self._driver._call_sdk( + self._driver._arm.set_position, + x=location.x, + y=location.y, + z=location.z, + roll=rotation.x, + pitch=rotation.y, + yaw=rotation.z, + speed=params.speed, + mvacc=params.mvacc, + wait=True, + op="set_position", + ) + + async def pick_up_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move to ``location`` and close the gripper to ``resource_width``.""" + await self.move_to_location(location, rotation, backend_params=backend_params) + await self.close_gripper(resource_width) + + async def drop_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move to ``location`` and fully open the gripper.""" + await self.move_to_location(location, rotation, backend_params=backend_params) + await self._set_gripper_units(self._MAX_GRIPPER_UNITS) + + # -- HasJoints ------------------------------------------------------------- + + async def move_to_joint_position( + self, position: JointPose, backend_params: Optional[BackendParams] = None + ) -> None: + """Move to the specified joint angles. + + Missing joint indices are filled in from the current joint position. + """ + params = self._joint_params(backend_params) + current_angles = await self._driver._call_sdk( + self._driver._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._driver._call_sdk( + self._driver._arm.set_servo_angle, + angle=angles, + speed=params.speed, + mvacc=params.mvacc, + wait=True, + op="set_servo_angle", + ) + + async def request_joint_position( + self, backend_params: Optional[BackendParams] = None + ) -> JointPose: + """Get current joint angles as ``{1: j1_deg, 2: j2_deg, ...}``.""" + angles = await self._driver._call_sdk(self._driver._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, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move to the joint target and close the gripper to ``resource_width``.""" + await self.move_to_joint_position(position, backend_params=backend_params) + await self.close_gripper(resource_width) + + async def drop_at_joint_position( + self, + position: JointPose, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move to the joint target and fully open the gripper.""" + await self.move_to_joint_position(position, backend_params=backend_params) + await self._set_gripper_units(self._MAX_GRIPPER_UNITS) + + # -- CanFreedrive ---------------------------------------------------------- + + async def start_freedrive_mode( + self, free_axes: List[int], backend_params: Optional[BackendParams] = None + ) -> None: + """Enter freedrive (manual teaching) mode. + + The xArm SDK only supports freeing all axes at once, so ``free_axes`` is + accepted for interface compatibility but ignored. + """ + await self._driver._call_sdk(self._driver._arm.set_mode, 2, op="set_mode") + await self._driver._call_sdk(self._driver._arm.set_state, 0, op="set_state") + + async def stop_freedrive_mode(self, backend_params: Optional[BackendParams] = None) -> None: + """Exit freedrive mode and return to position control.""" + await self._driver._call_sdk(self._driver._arm.set_mode, 0, op="set_mode") + await self._driver._call_sdk(self._driver._arm.set_state, 0, op="set_state") diff --git a/pylabrobot/ufactory/xarm6/backend_tests.py b/pylabrobot/ufactory/xarm6/backend_tests.py new file mode 100644 index 00000000000..9977d277d5d --- /dev/null +++ b/pylabrobot/ufactory/xarm6/backend_tests.py @@ -0,0 +1,229 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.ufactory.xarm6.backend import XArm6ArmBackend +from pylabrobot.ufactory.xarm6.driver import XArm6Driver + + +class TestXArm6ArmBackend(unittest.IsolatedAsyncioTestCase): + def _make_driver(self) -> MagicMock: + """Build a mock driver whose _call_sdk returns sensible values per SDK method.""" + driver = MagicMock(spec=XArm6Driver) + driver._arm = MagicMock() + + sdk_returns = { + driver._arm.get_position: [100, 200, 300, 180, 0, 90], + driver._arm.get_servo_angle: [10, 20, 30, 40, 50, 60], + driver._arm.get_gripper_position: 850, + } + + async def call_sdk(func, *args, op="", num_retries=0, **kwargs): + return sdk_returns.get(func) + + driver._call_sdk = AsyncMock(side_effect=call_sdk) + driver.clear_errors = AsyncMock() + return driver + + def setUp(self): + self.driver = self._make_driver() + self.backend = XArm6ArmBackend(driver=self.driver) + + def _sdk_calls_for(self, func) -> list: + return [c for c in self.driver._call_sdk.call_args_list if c.args and c.args[0] is func] + + # -- Gripper --------------------------------------------------------------- + + async def test_open_gripper_mm_to_units(self): + await self.backend.open_gripper(gripper_width=85) + calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].args[1], 850) + self.assertEqual(calls[0].kwargs["wait"], True) + self.assertEqual(calls[0].kwargs["speed"], 0) + + async def test_open_gripper_half(self): + await self.backend.open_gripper(gripper_width=42.5) + calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 425) + + async def test_close_gripper(self): + await self.backend.close_gripper(gripper_width=0) + calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 0) + + async def test_open_gripper_clamped_high(self): + await self.backend.open_gripper(gripper_width=200) + calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 850) + + async def test_open_gripper_clamped_low(self): + await self.backend.open_gripper(gripper_width=-5) + calls = self._sdk_calls_for(self.driver._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.driver._call_sdk = AsyncMock(side_effect=call_sdk) + self.assertTrue(await self.backend.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.driver._call_sdk = AsyncMock(side_effect=call_sdk) + self.assertFalse(await self.backend.is_gripper_closed()) + + # -- Base arm -------------------------------------------------------------- + + async def test_halt(self): + await self.backend.halt() + self.assertEqual(len(self._sdk_calls_for(self.driver._arm.emergency_stop)), 1) + + async def test_park_default_home_uses_retry(self): + await self.backend.park() + calls = self._sdk_calls_for(self.driver._arm.move_gohome) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs["num_retries"], 1) + self.assertEqual(len(self._sdk_calls_for(self.driver._arm.set_position)), 0) + + async def test_park_with_location(self): + backend = XArm6ArmBackend( + driver=self.driver, + park_location=Coordinate(x=250, y=0, z=300), + park_rotation=Rotation(x=180, y=0, z=0), + ) + await backend.park() + self.assertEqual(len(self._sdk_calls_for(self.driver._arm.move_gohome)), 0) + set_pos_calls = self._sdk_calls_for(self.driver._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_location(self): + location = await self.backend.request_gripper_location() + 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.backend.move_to_location(Coordinate(x=300, y=100, z=200), Rotation(x=180, y=0, z=0)) + calls = self._sdk_calls_for(self.driver._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_backend_params(self): + await self.backend.move_to_location( + Coordinate(x=0, y=0, z=0), + Rotation(), + backend_params=XArm6ArmBackend.CartesianMoveParams(speed=250, mvacc=3500), + ) + calls = self._sdk_calls_for(self.driver._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.backend.pick_up_at_location(loc, rot, resource_width=80) + + mcalls = self._sdk_calls_for(self.driver._arm.set_position) + self.assertEqual(len(mcalls), 1) + self.assertEqual(mcalls[0].kwargs["z"], 50) + + grip_calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(len(grip_calls), 1) + self.assertEqual(grip_calls[0].args[1], 800) # 80 mm → 800 units + + 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.backend.drop_at_location(loc, rot, resource_width=80) + + mcalls = self._sdk_calls_for(self.driver._arm.set_position) + self.assertEqual(len(mcalls), 1) + self.assertEqual(mcalls[0].kwargs["z"], 50) + + grip_calls = self._sdk_calls_for(self.driver._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.backend.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.backend.move_to_joint_position({1: 45, 3: -90}) + self.assertEqual(len(self._sdk_calls_for(self.driver._arm.get_servo_angle)), 1) + set_calls = self._sdk_calls_for(self.driver._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_backend_params(self): + await self.backend.move_to_joint_position( + {1: 0}, + backend_params=XArm6ArmBackend.JointMoveParams(speed=120, mvacc=900), + ) + set_calls = self._sdk_calls_for(self.driver._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.backend.pick_up_at_joint_position({1: 0, 2: 0}, resource_width=80) + self.assertEqual(len(self._sdk_calls_for(self.driver._arm.set_servo_angle)), 1) + grip_calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(len(grip_calls), 1) + self.assertEqual(grip_calls[0].args[1], 800) + + async def test_drop_at_joint_position(self): + await self.backend.drop_at_joint_position({1: 0, 2: 0}, resource_width=80) + self.assertEqual(len(self._sdk_calls_for(self.driver._arm.set_servo_angle)), 1) + grip_calls = self._sdk_calls_for(self.driver._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.backend.start_freedrive_mode(free_axes=[0]) + mode_calls = self._sdk_calls_for(self.driver._arm.set_mode) + state_calls = self._sdk_calls_for(self.driver._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.backend.stop_freedrive_mode() + mode_calls = self._sdk_calls_for(self.driver._arm.set_mode) + state_calls = self._sdk_calls_for(self.driver._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_mm_per_gripper_unit(self): + backend = XArm6ArmBackend(driver=self.driver, mm_per_gripper_unit=0.2) + await backend.open_gripper(gripper_width=85) + calls = self._sdk_calls_for(self.driver._arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 425) diff --git a/pylabrobot/ufactory/xarm6/driver.py b/pylabrobot/ufactory/xarm6/driver.py new file mode 100644 index 00000000000..b6b4e52c7c9 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/driver.py @@ -0,0 +1,142 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, List, Optional, Tuple + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver + + +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 XArm6Driver(Driver): + """Driver for the UFACTORY xArm 6 robotic arm. + + Owns the ``XArmAPI`` SDK instance and handles the connection lifecycle, + error recovery, and the shared motion-profile defaults used by the + capability backend. Exposes the SDK via :attr:`_arm` plus the + :meth:`call` / :meth:`check` helpers, which the backend uses to issue + motion and gripper commands. + + 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]). + """ + + def __init__( + self, + ip: str, + tcp_offset: Optional[Tuple[float, float, float, float, float, float]] = None, + tcp_load: Optional[Tuple[float, List[float]]] = None, + ): + super().__init__() + self._ip = ip + self._arm: Any = None + self._tcp_offset = tcp_offset + self._tcp_load = tcp_load + + # -- 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 ------------------------------------------------------------- + + @dataclass + class SetupParams(BackendParams): + """xArm-specific parameters for ``setup``. + + Args: + skip_gripper_init: If True, skip gripper mode/enable during setup. + """ + + skip_gripper_init: bool = False + + 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, backend_params: Optional[BackendParams] = None) -> None: + """Connect to the xArm and initialize for position control.""" + if not isinstance(backend_params, XArm6Driver.SetupParams): + backend_params = XArm6Driver.SetupParams() + + from xarm.wrapper import XArmAPI # type: ignore[import-not-found] + + 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 backend_params.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 diff --git a/pylabrobot/ufactory/xarm6/driver_tests.py b/pylabrobot/ufactory/xarm6/driver_tests.py new file mode 100644 index 00000000000..3c91854eee9 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/driver_tests.py @@ -0,0 +1,135 @@ +import sys +import types +import unittest +from unittest.mock import MagicMock + +from pylabrobot.ufactory.xarm6.driver import XArm6Driver, 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 TestXArm6Driver(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 = XArm6Driver(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(0) + self.mock_arm.set_gripper_enable.assert_called_once_with(True) + + async def test_setup_with_tcp_offset(self): + driver = XArm6Driver(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 = XArm6Driver(ip="192.168.1.113") + self.mock_arm.set_gripper_mode.reset_mock() + self.mock_arm.set_gripper_enable.reset_mock() + await driver.setup(backend_params=XArm6Driver.SetupParams(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) diff --git a/pylabrobot/ufactory/xarm6/joints.py b/pylabrobot/ufactory/xarm6/joints.py new file mode 100644 index 00000000000..b4c90745028 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/joints.py @@ -0,0 +1,12 @@ +from enum import IntEnum + + +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 diff --git a/pylabrobot/ufactory/xarm6/xarm6.py b/pylabrobot/ufactory/xarm6/xarm6.py new file mode 100644 index 00000000000..ce0fa858967 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/xarm6.py @@ -0,0 +1,27 @@ +from pylabrobot.capabilities.arms.articulated_arm import ArticulatedArm +from pylabrobot.device import Device +from pylabrobot.resources import Resource +from pylabrobot.ufactory.xarm6.backend import XArm6ArmBackend +from pylabrobot.ufactory.xarm6.driver import XArm6Driver + + +class XArm6(Device): + """UFACTORY xArm 6 robotic arm with bio-gripper. + + Composes an :class:`XArm6Driver`, a default :class:`XArm6ArmBackend`, and an + :class:`ArticulatedArm` frontend. The arm capability is exposed as + ``self.arm``; joint-space and freedrive operations live on + ``self.arm.backend``. + + Args: + driver: Pre-configured :class:`XArm6Driver` (holds IP, speed/accel + defaults, TCP offset/load, etc.). + """ + + def __init__(self, driver: XArm6Driver) -> None: + super().__init__(driver=driver) + self.driver: XArm6Driver = driver + backend = XArm6ArmBackend(driver=driver) + self.reference = Resource(name="XArm6", size_x=200, size_y=200, size_z=200) + self.arm = ArticulatedArm(backend=backend, reference_resource=self.reference) + self._capabilities = [self.arm] diff --git a/pylabrobot/visualizer/lib.js b/pylabrobot/visualizer/lib.js index c3cee375662..c0fc67594f0 100644 --- a/pylabrobot/visualizer/lib.js +++ b/pylabrobot/visualizer/lib.js @@ -2493,7 +2493,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 42358877444..9f7667e1102 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 a1225c3cd47..c873d4096bd 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.0"] 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", diff --git a/todo-documentation-backendparameters.md b/todo-documentation-backendparameters.md new file mode 100644 index 00000000000..d9a2d9b3de7 --- /dev/null +++ b/todo-documentation-backendparameters.md @@ -0,0 +1,77 @@ +# BackendParams Classes Needing Documentation + +These classes have no existing documentation source and need manual documentation +(likely requires hardware manual or domain expertise): + +(none -- all BackendParams classes have been documented) + +## Summary of documentation added + +All BackendParams dataclasses in the codebase have been documented. The following +classes received new or expanded docstrings in this pass: + +### Hamilton STAR PIP backend (`pylabrobot.hamilton.liquid_handlers.star.pip_backend`) +- `STARPIPBackend.PickUpTipsParams` -- expanded from one-liner +- `STARPIPBackend.DropTipsParams` -- expanded from one-liner +- `STARPIPBackend.AspirateParams` -- expanded from one-liner (legacy source: `aspirate_pip`) +- `STARPIPBackend.DispenseParams` -- expanded from one-liner (legacy source: `dispense_pip`) + +### Hamilton STAR 96-head backend (`pylabrobot.hamilton.liquid_handlers.star.head96_backend`) +- `STARHead96Backend.PickUpTips96Params` -- expanded from one-liner +- `STARHead96Backend.DropTips96Params` -- expanded from one-liner +- `STARHead96Backend.Aspirate96Params` -- expanded from one-liner +- `STARHead96Backend.Dispense96Params` -- expanded from one-liner + +### Hamilton iSWAP backend (`pylabrobot.hamilton.liquid_handlers.star.iswap`) +- `iSWAPBackend.ParkParams` -- new docstring +- `iSWAPBackend.CloseGripperParams` -- new docstring +- `iSWAPBackend.PickUpParams` -- new docstring (legacy source: `iswap_get_plate`) +- `iSWAPBackend.DropParams` -- new docstring (legacy source: `iswap_put_plate`) +- `iSWAPBackend.MoveToLocationParams` -- new docstring (legacy source: `move_plate_to_position`) + +### Hamilton CoRe gripper backend (`pylabrobot.hamilton.liquid_handlers.star.core`) +- `CoreGripper.PickUpParams` -- new docstring +- `CoreGripper.DropParams` -- new docstring +- `CoreGripper.MoveToLocationParams` -- new docstring + +### Azenta XPeel (`pylabrobot.azenta.xpeel`) +- `XPeelPeelerBackend.PeelParams` -- new docstring + +### BMG Labtech CLARIOstar (`pylabrobot.bmg_labtech.clariostar.absorbance_backend`) +- `CLARIOstarAbsorbanceParams` -- new docstring + +### Byonoy Luminescence 96 (`pylabrobot.byonoy.luminescence_96`) +- `Luminescence96.LuminescenceParams` -- new docstring + +### Agilent VSpin (`pylabrobot.agilent.vspin.vspin`) +- `VSpinCentrifugeBackend.SpinParams` -- new docstring + +### Agilent BioTek Cytation (`pylabrobot.agilent.biotek.cytation`) +- `CytationImagingBackend.CaptureParams` -- new docstring + +### Agilent BioTek (`pylabrobot.agilent.biotek.biotek`) +- `BioTekBackend.LuminescenceParams` -- new docstring + +### Molecular Devices SpectraMax M5 (`pylabrobot.molecular_devices.spectramax`) +- `SpectraMaxM5FluorescenceBackend.FluorescenceParams` -- new docstring +- `SpectraMaxM5LuminescenceBackend.LuminescenceParams` -- new docstring +- `MolecularDevicesAbsorbanceBackend.AbsorbanceParams` -- new docstring + +### Brooks PreciseFlex (`pylabrobot.brooks.precise_flex`) +- `PreciseFlexArmBackend.PickUpParams` -- new docstring +- `PreciseFlexArmBackend.DropParams` -- new docstring +- `PreciseFlexArmBackend.MoveToJointPositionParams` -- new docstring +- `PreciseFlexArmBackend.MoveToLocationParams` -- new docstring + +### Already documented (no changes needed) +- `MultidropCombiPeristalticDispensingBackend.DispenseParams` +- `MultidropCombiPeristalticDispensingBackend.PrimeParams` +- `MultidropCombiPeristalticDispensingBackend.PurgeParams` +- `EL406SyringeDispensingBackend.DispenseParams` +- `EL406SyringeDispensingBackend.PrimeParams` +- `EL406PlateWashingBackend.WashParams` +- `EL406PlateWashingBackend.PrimeParams` +- `EL406PeristalticDispensingBackend.DispenseParams` +- `EL406PeristalticDispensingBackend.PrimeParams` +- `_DictBackendParams` (legacy wrapper) +- `BackendParams` (base class)