diff --git a/.gitignore b/.gitignore
index 10d2b64ea58..bc615036d54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,10 @@ pyhamilton/LAY-BACKUP
.ipynb_checkpoints
*.egg-info
*.log
+keyser-testing/Tecan Manuals/
+keyser-testing/evoware-usb-capture-*/
+keyser-testing/evoware-pktmon-capture-*/
+keyser-testing/*.pdf
build/lib
myenv
diff --git a/_typos.toml b/_typos.toml
index 742efae9454..3cf64e87e10 100644
--- a/_typos.toml
+++ b/_typos.toml
@@ -28,8 +28,21 @@ mis = "mis"
RHE = "RHE"
"ASEND" = "ASEND"
caf = "caf"
+# Tecan firmware command abbreviations
+ALO = "ALO"
+SOM = "SOM"
+SHS = "SHS"
+SHW = "SHW"
+AZMA = "AZMA"
+MCH = "MCH"
+AAS = "AAS"
+AER = "AER"
[files]
extend-exclude = [
- "*.ipynb"
+ "*.ipynb",
+ "keyser-testing/Tecan Manuals/",
+ "keyser-testing/evoware-usb-capture-aspirate-dispense/",
+ "keyser-testing/evoware-usb-capture-multidispense/",
+ "keyser-testing/evoware-pktmon-capture-20260327/",
]
diff --git a/keyser-testing/AirLiHa_Implementation_Plan.md b/keyser-testing/AirLiHa_Implementation_Plan.md
new file mode 100644
index 00000000000..feddd4c1a53
--- /dev/null
+++ b/keyser-testing/AirLiHa_Implementation_Plan.md
@@ -0,0 +1,132 @@
+# Air LiHa Implementation Plan
+
+## Architecture Decision
+
+### Option Considered: New LiHa Subclass
+The LiHa class (`EVO_backend.py` lines 893-1203) is a thin firmware command wrapper — it sends commands like `PVL`, `SEP`, `PPR` etc. It doesn't contain conversion logic or volume calculations. The conversion factors (`* 3` for steps, `* 6` for speed) live in the EVOBackend methods (`_aspirate_action`, `_dispense_action`, `_aspirate_airgap`, `pick_up_tips`). A LiHa subclass would not help.
+
+### Option Considered: Parameterize Existing EVOBackend
+Adding `steps_per_ul` and `speed_factor` parameters to EVOBackend would change the constructor signature and add complexity for all users, even those with syringe LiHa.
+
+### Recommended: EVOBackend Subclass (`AirEVOBackend`)
+**Rationale:**
+- The Air LiHa differs from syringe LiHa in **init sequence**, **conversion factors**, and **per-operation commands** — these are all EVOBackend-level concerns
+- Subclassing keeps the existing EVOBackend completely untouched
+- Clean separation: syringe users use `EVOBackend`, air users use `AirEVOBackend`
+- The ZaapMotion configuration is specific to the Air LiHa hardware variant
+- `TipType.AIRDITI` already exists in the codebase — just needs liquid class data
+
+## Implementation Steps
+
+### Step 1: Create `AirEVOBackend` class
+**File:** `pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py`
+
+```python
+class AirEVOBackend(EVOBackend):
+```
+
+**Overrides:**
+- `setup()` — adds ZaapMotion boot exit + motor config before calling `super().setup()`
+- `_aspirate_action()` — uses 106.4/213 conversion factors instead of 3/6
+- `_dispense_action()` — same conversion factor change
+- `_aspirate_airgap()` — same conversion factor change
+- `pick_up_tips()` — same conversion factor change
+- `aspirate()` — wraps with SFR/SFP/SDP commands around plunger operations
+- `dispense()` — wraps with SFR/SFP/SDP commands around plunger operations
+- `drop_tips()` — adds SDT command before AST, wraps with SFR/SFP/SDP
+
+**Constants (class-level):**
+```python
+STEPS_PER_UL = 106.4 # vs 3 for syringe
+SPEED_FACTOR = 213 # vs 6 for syringe (half-steps/sec per µL/s)
+SFR_ACTIVE = 133120 # force ramp during plunger movement
+SFR_IDLE = 3752 # force ramp at rest
+SDP_DEFAULT = 1400 # default dispense parameter
+```
+
+**ZaapMotion config data:**
+```python
+ZAAPMOTION_CONFIG = [ # 33 commands per tip, from USB capture
+ "CFE 255,500",
+ "CAD ADCA,0,12.5",
+ ...
+ "WRP",
+]
+```
+
+### Step 2: Add ZaapMotion liquid classes
+**File:** `pylabrobot/liquid_handling/liquid_classes/tecan.py`
+
+Add entries to the `mapping` dict with `TipType.AIRDITI` key, parsed from `DefaultLCs.XML`. Start with the most common liquid classes:
+- Water free dispense (DiTi 50)
+- Water wet contact (DiTi 50)
+- DMSO free dispense (DiTi 50)
+
+Each liquid class entry maps a `(min_vol, max_vol, Liquid, TipType.AIRDITI)` tuple to a `TecanLiquidClass` with the calibration factor, speeds, air gap volumes, and LLD settings from the XML.
+
+### Step 3: Export the new backend
+**File:** `pylabrobot/liquid_handling/backends/tecan/__init__.py`
+
+Add `AirEVOBackend` to exports.
+
+### Step 4: Add helper method for ZaapMotion commands
+**In `AirEVOBackend`:**
+
+```python
+async def _zaapmotion_force_mode(self, enable: bool):
+ """Send SFR/SFP/SDP to all tips for force mode control."""
+ sfr_val = self.SFR_ACTIVE if enable else self.SFR_IDLE
+ for tip in range(8):
+ await self.send_command("C5", command=f"T2{tip}SFR{sfr_val}")
+ if enable:
+ for tip in range(8):
+ await self.send_command("C5", command=f"T2{tip}SFP1")
+ else:
+ for tip in range(8):
+ await self.send_command("C5", command=f"T2{tip}SDP{self.SDP_DEFAULT}")
+```
+
+### Step 5: Tests
+**File:** `pylabrobot/liquid_handling/backends/tecan/air_evo_tests.py`
+
+- Test conversion factors: verify `_aspirate_action` produces correct steps for known volumes
+- Test ZaapMotion config sequence: mock USB and verify all 33 × 8 config commands are sent
+- Test SFR/SFP/SDP wrapping: verify force mode commands surround plunger operations
+- Test setup sequence: verify boot exit + config + safety + PIA order
+
+## Files Changed
+
+| File | Change | Risk |
+|------|--------|------|
+| `backends/tecan/air_evo_backend.py` | **NEW** — AirEVOBackend subclass | None (new file) |
+| `backends/tecan/__init__.py` | Add export | Minimal |
+| `liquid_classes/tecan.py` | Add ZaapDiTi mapping entries | None (additive) |
+| `backends/tecan/air_evo_tests.py` | **NEW** — tests | None (new file) |
+
+**Files NOT changed:**
+- `EVO_backend.py` — untouched, syringe LiHa users unaffected
+- `tip_creators.py` — `TipType.AIRDITI` already exists
+- `machine.py`, `backend.py` — no framework changes
+
+## Usage
+
+```python
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import AirEVOBackend
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+backend = AirEVOBackend(diti_count=8)
+deck = EVO150Deck()
+lh = LiquidHandler(backend=backend, deck=deck)
+await lh.setup() # handles ZaapMotion boot exit, config, safety, PIA
+```
+
+## Open Questions
+
+1. **Should ZaapMotion config values be constructor parameters?** The PID gains, current limits, etc. may vary between instruments. For now, hardcode the values from our USB capture; parameterize later if needed.
+
+2. **Should `_zaapmotion_force_mode` send to all 8 tips or only active channels?** EVOware always sends to all 8 regardless of which channels are in use. Start with all 8 for compatibility.
+
+3. **Does `WRP` (write to flash) make the config persistent?** If so, the config commands only need to be sent on first boot after power cycle. We could check `RCS` (Report Configuration Status) first and skip config if already configured. EVOware does this — it checks `RCS` and skips the full config if the ZaapMotion is already in app mode with config present.
+
+4. **Multi-dispense and other advanced operations** — the USB capture with multidispense shows the same SFR/SFP/SDP pattern. No additional ZaapMotion commands are needed beyond what's described here.
diff --git a/keyser-testing/AirLiHa_Investigation.md b/keyser-testing/AirLiHa_Investigation.md
new file mode 100644
index 00000000000..b3f7bfacd77
--- /dev/null
+++ b/keyser-testing/AirLiHa_Investigation.md
@@ -0,0 +1,226 @@
+# Air LiHa (ZaapMotion) Investigation for Tecan EVO 150
+
+## Background
+
+The Tecan Freedom EVO 150 can be equipped with two types of Liquid Handling Arms (LiHa):
+
+- **Syringe LiHa** — positive displacement using XP2000/XP6000 syringe dilutors
+- **Air LiHa** — air displacement using ZaapMotion BLDC motor controllers
+
+The existing pylabrobot `EVOBackend` was written for syringe-based LiHa. This document describes the investigation into what changes are needed to support Air LiHa with ZaapMotion controllers.
+
+## Investigation Method
+
+1. **EVOware firmware logs** — captured EVOware's command log during initialization and liquid handling operations
+2. **USB packet capture** — used USBPcap + Wireshark to capture raw USB traffic between EVOware and the TeCU, revealing commands that EVOware's log does not show
+3. **DLL string analysis** — extracted strings from `zaapmotiondriver.dll` to understand its configuration model
+4. **Iterative testing** — tested individual firmware commands from pylabrobot to isolate the initialization problem
+
+## Finding 1: ZaapMotion Boot Mode
+
+### Problem
+After every power cycle, all 8 ZaapMotion dilutor controllers boot into **bootloader mode** (`XP2-BOOT`, mode `ZMB`). In this state, the Z-axis motors cannot perform homing, so `PIA` (Position Initialization All Axes) always fails with error code 1 on all Z-axes.
+
+### Root Cause
+The ZaapMotion controllers have firmware in onboard flash but default to bootloader mode on power-up. EVOware's `zaapmotiondriver.dll` sends an `X` (exit boot) command via the transparent pipeline (`T2xX`) to jump each controller to application mode.
+
+### Evidence
+```
+# After power cycle — bootloader mode
+> C5,T20RFV0 → XP2-BOOT-V1.00-05/2011, 1.0.0.9506, ZMB
+
+# After sending T20X — application mode
+> C5,T20RFV0 → XP2000-V1.20-02/2015, 1.2.0.10946, ZMA
+```
+
+### Fix
+Send `C5,T2{0-7}X` to each tip before attempting PIA. Wait ~1 second after each for the application firmware to start.
+
+## Finding 2: ZaapMotion Motor Configuration
+
+### Problem
+Even after exiting boot mode, PIA still fails. The Z-axis BLDC motors don't have their PID gains, current limits, encoder settings, or init parameters configured.
+
+### Root Cause
+EVOware's `zaapmotiondriver.dll` sends ~30 motor configuration commands per tip during its 30-second "Scanning for and configuring ZaapMotion Axes" phase. These commands are sent via the transparent pipeline (`T2x`) and are **not logged in EVOware's firmware command log** — they were only visible in the USB packet capture.
+
+### Configuration Sequence (per tip, via transparent pipeline T2x)
+```
+RFV — check firmware version (verify app mode)
+CFE 255,500 — configure force/current enable
+CAD ADCA,0,12.5 / CAD ADCB,1,12.5 — ADC configuration
+EDF1 — enable drive function 1
+EDF4 — enable drive function 4
+CDO 11 — configure drive output
+EDF5 — enable drive function 5
+SIC 10,5 — set init current (10 amps, 5 ?)
+SEA ADD,H,4,STOP,1,0,0 — set encoder/axis config
+CMTBLDC,1 — configure motor type = Brushless DC
+CETQEP2,256,R — configure encoder type = QEP2, 256 counts, reversed
+CECPOS,QEP2 — connect position feedback to QEP2
+CECCUR,QEP2 — connect current feedback to QEP2
+CEE OFF — controller encoder enable off
+STL80 — set torque limit = 80%
+SVL12,8,16 / SVL24,20,28 — set voltage limits
+SCL1,900,3.5 — set current limit
+SCE HOLD,500 / SCE MOVE,500 — set current for hold/move modes
+CIR0 — clear integral reset
+PIDHOLD,D,1.2,1,-1,0.003,0,0,OFF — PID D-axis hold mode
+PIDMOVE,D,0.8,1,-1,0.004,0,0,OFF — PID D-axis move mode
+PIDHOLD,Q,1.2,1,-1,0.003,0,0,OFF — PID Q-axis hold mode
+PIDMOVE,Q,0.8,1,-1,0.004,0,0,OFF — PID Q-axis move mode
+PIDHOLD,POS,0.2,1,-1,0.02,4,0,OFF — PID position hold mode
+PIDMOVE,POS,0.35,1,-1,0.1,3,0,OFF — PID position move mode
+PIDSPDELAY,0 — PID switch delay = 0
+SFF 0.045,0.4,0.041 — set force factors
+SES 0 — set encoder setting
+SPO0 — set position offset = 0
+SIA 0.01, 0.28, 0.0 — set init acceleration
+WRP — write all parameters to flash
+```
+
+### Evidence
+USB capture file: `keyser-testing/tecan-2/tecan.pcap` — 11,727 USB packets showing the complete init sequence including all T2x commands.
+
+### Fix
+Send the above 33 commands to each of the 8 tips (T20-T27) before PIA. Validated in `keyser-testing/zaapmotion_init.py` — PIA succeeds with all 8 Z-axes after configuration.
+
+## Finding 3: Safety Module Commands
+
+### Context
+EVOware sends safety module (O1) commands before PIA:
+```
+O1,SPN — power on
+O1,SPS3 — set power state = 3 (full power)
+```
+
+These ensure the motor drivers have full power. Without them, Z-axis homing may be unreliable. The existing pylabrobot `EVOBackend.setup()` does not send these commands.
+
+## Finding 4: Air LiHa Plunger Conversion Factors
+
+### Problem
+The existing EVOBackend uses `volume * 3` for plunger steps and `speed * 6` for plunger speed — these are specific to syringe-based XP2000/XP6000 dilutors.
+
+### Correct Conversion for Air LiHa
+Derived from USB captures correlating `CalculateProfile` log entries with actual `PPR`/`MTR` firmware commands:
+
+| Volume (µL) | Command | Steps | Steps/µL |
+|-------------|---------|-------|----------|
+| 2.5 | PPR -266 | 266 | 106.40 |
+| 5.0 | PPR 532 | 532 | 106.40 |
+| 10.0 | PPR 1065 | 1065 | 106.50 |
+| 15.0 | PPR 1597 | 1597 | 106.47 |
+| 18.8 | MTR -1996 | 1996 | 106.17 |
+| 25.0 | PPR 2662 / MTR -2662 | 2662 | 106.48 |
+| 30.0 | MTR -3195 | 3195 | 106.50 |
+| 31.3 | MTR 3328 | 3328 | 106.33 |
+| 42.3 | MTR -4499 | 4499 | 106.41 |
+| 47.9 | MTR 5096 | 5096 | 106.39 |
+| 62.9 | MTR -6693 | 6693 | 106.41 |
+
+**Air LiHa conversion factors:**
+- **106.4 steps/µL** (vs 3 for syringe LiHa — 35x difference)
+- **213 half-steps/sec per µL/s** for speed (vs 6 for syringe — 35x difference)
+
+### Evidence
+- EVOware log: `keyser-testing/multidispense pro/EVO_20260327_125527.LOG`
+- USB capture: `keyser-testing/simple pro/tecan2.pcap`, `keyser-testing/multidispense pro/tecan3.pcap`
+
+## Finding 5: ZaapMotion Per-Operation Commands
+
+### Problem
+EVOware sends ZaapMotion-specific commands before and after every plunger operation (aspirate, dispense, tip discard). These are not present in the existing pylabrobot code.
+
+### Command Pattern
+Before each plunger move:
+```
+T2xSFR133120 — Set force ramp (high value for acceleration)
+SEP/SPP — Set plunger end/stop speed (standard LiHa command)
+T2xSFP1 — Enable force mode
+```
+
+After each plunger move:
+```
+T2xSFR3752 — Set force ramp (low value for hold/idle)
+T2xSDP1400 — Set dispense parameter (default/idle)
+```
+
+This pattern is identical for aspirate, dispense, and tip discard operations.
+
+### Additional: Tip Discard
+EVOware sends `C5,SDT1,1000,200` (Set DiTi discard parameters) before `AST` (drop tip). The existing pylabrobot code calls `AST` directly without this setup command.
+
+## Finding 6: Liquid Class Data
+
+### Source
+EVOware liquid class XML files captured from `C:\ProgramData\Tecan\EVOware\database\`:
+- `DefaultLCs.XML` (1.4 MB) — 93 ZaapDiTi liquid class entries
+- `CustomLCs.XML` (437 KB)
+- Location: `keyser-testing/multidispense pro/`
+
+### Key Data Points per Liquid Class
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Existing Support
+`TipType.AIRDITI = "ZaapDiTi"` already exists in pylabrobot's `TipType` enum (`pylabrobot/resources/tecan/tip_creators.py`), but no liquid class mapping entries use it.
+
+## Summary of Required Changes
+
+### 1. Initialization (EVOBackend.setup)
+- Exit ZaapMotion boot mode: `T2{0-7}X`
+- Send 33 motor configuration commands per tip
+- Send safety module commands: `O1,SPN` and `O1,SPS3`
+- Then proceed with existing PIA sequence
+
+### 2. Plunger Conversions
+- Replace hardcoded `* 3` (steps) with `* 106.4` for Air LiHa
+- Replace hardcoded `* 6` (speed) with `* 213` for Air LiHa
+- Affects: `_aspirate_airgap()`, `_aspirate_action()`, `_dispense_action()`, `pick_up_tips()`
+
+### 3. Per-Operation ZaapMotion Commands
+- Add `SFR133120` + `SFP1` before each plunger operation
+- Add `SFR3752` + `SDP1400` after each plunger operation
+- Add `SDT1,1000,200` before tip discard
+
+### 4. Liquid Classes
+- Parse ZaapDiTi entries from DefaultLCs.XML into pylabrobot's `mapping` dict
+- Map to existing `TecanLiquidClass` fields
+
+## Hardware Details
+
+- **TeCU**: TECU-V1.40-12/2007
+- **LiHa CU**: LIHACU-V1.80-02/2016, 8 air channels
+- **ZaapMotion boot**: XP2-BOOT-V1.00-05/2011
+- **ZaapMotion app**: XP2000-V1.20-02/2015
+- **Safety module**: SAFY-V1.30-04/2008
+- **RoMa**: ROMACU-V2.21-09/2007
+- **Tips**: DiTi 50µL SBS LiHa (ZaapDiTi tip type)
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `keyser-testing/zaapmotion_init.py` | Working init script (boot exit + motor config + PIA) |
+| `keyser-testing/tecan-2/tecan.pcap` | USB capture of EVOware init sequence |
+| `keyser-testing/simple pro/tecan2.pcap` | USB capture of aspirate/dispense/discard |
+| `keyser-testing/multidispense pro/tecan3.pcap` | USB capture with multiple volumes |
+| `keyser-testing/multidispense pro/DefaultLCs.XML` | EVOware liquid class definitions |
+| `keyser-testing/multidispense pro/EVO_20260327_125527.LOG` | EVOware log with volume calculations |
+| `keyser-testing/Tecan Manuals/text/` | Firmware command set manuals (LiHa, RoMa, MCA, PnP) |
diff --git a/keyser-testing/ZaapMotionAdapter.dll b/keyser-testing/ZaapMotionAdapter.dll
new file mode 100644
index 00000000000..c852a7eb53a
Binary files /dev/null and b/keyser-testing/ZaapMotionAdapter.dll differ
diff --git a/keyser-testing/ZaapMotionDriver.dll b/keyser-testing/ZaapMotionDriver.dll
new file mode 100644
index 00000000000..ddb51ac4f1e
Binary files /dev/null and b/keyser-testing/ZaapMotionDriver.dll differ
diff --git a/keyser-testing/decode_usb_capture.py b/keyser-testing/decode_usb_capture.py
new file mode 100644
index 00000000000..2b7978ef61b
--- /dev/null
+++ b/keyser-testing/decode_usb_capture.py
@@ -0,0 +1,116 @@
+"""Decode Tecan EVO USB capture from Wireshark.
+
+Extracts and decodes Tecan firmware commands from a USB pcap file.
+Save your Wireshark capture as a .pcap or .pcapng file, then run:
+
+ python keyser-testing/decode_usb_capture.py
+
+Prerequisites:
+ pip install pyshark (or: pip install scapy)
+
+Alternative: Export from Wireshark as CSV/JSON and use the manual decode below.
+
+If you don't want to install pyshark, you can also:
+1. In Wireshark, filter by: usb.transfer_type == 0x03 (bulk transfer)
+2. Look for packets to/from the Tecan device (VID=0x0C47, PID=0x4000)
+3. Export packet bytes — Tecan commands start with 0x02 (STX) and end with 0x00 (NUL)
+4. The format is: STX + module(2 chars) + command(3 chars) + params(comma-sep) + NUL
+"""
+
+import sys
+
+
+def decode_tecan_packet(data: bytes, direction: str = "?") -> str:
+ """Decode a single Tecan USB packet.
+
+ Args:
+ data: Raw packet bytes
+ direction: "TX" (host→device) or "RX" (device→host)
+
+ Returns:
+ Human-readable string describing the command/response.
+ """
+ if len(data) < 4:
+ return f"[{direction}] Too short: {data.hex()}"
+
+ # Tecan commands: STX(0x02) + module + command + params + NUL(0x00)
+ if data[0] == 0x02 and data[-1] == 0x00:
+ payload = data[1:-1].decode("ascii", errors="replace")
+ module = payload[:2]
+ cmd_and_params = payload[2:]
+
+ # Split command (3 chars) from params
+ cmd = cmd_and_params[:3]
+ params = cmd_and_params[3:] if len(cmd_and_params) > 3 else ""
+
+ return f"[{direction}] {module} {cmd} {params}"
+
+ # Tecan responses: STX(0x02) + module(2) + status_byte + data + NUL(0x00)
+ if data[0] == 0x02:
+ payload = data[1:-1] if data[-1] == 0x00 else data[1:]
+ if len(payload) >= 3:
+ module = payload[:2].decode("ascii", errors="replace")
+ status = payload[2] ^ 0x80 # bit 7 is parity
+ rest = payload[3:].decode("ascii", errors="replace") if len(payload) > 3 else ""
+ return f"[{direction}] {module} status={status} data={rest}"
+
+ return f"[{direction}] Raw: {data.hex()}"
+
+
+def decode_hex_dump(hex_string: str, direction: str = "?") -> str:
+ """Decode from a hex string (e.g., copied from Wireshark hex view)."""
+ clean = hex_string.replace(" ", "").replace("\n", "").replace(":", "")
+ data = bytes.fromhex(clean)
+ return decode_tecan_packet(data, direction)
+
+
+# ── Interactive mode ──
+def interactive():
+ """Paste hex strings from Wireshark to decode them."""
+ print("Tecan USB Packet Decoder")
+ print("Paste hex bytes from Wireshark (e.g., '02 43 35 50 49 41 00')")
+ print("Prefix with > for TX (host→device) or < for RX (device→host)")
+ print("Type 'q' to quit.\n")
+
+ while True:
+ line = input("hex> ").strip()
+ if line.lower() == "q":
+ break
+ if not line:
+ continue
+
+ direction = "?"
+ if line.startswith(">"):
+ direction = "TX"
+ line = line[1:].strip()
+ elif line.startswith("<"):
+ direction = "RX"
+ line = line[1:].strip()
+
+ try:
+ print(f" {decode_hex_dump(line, direction)}")
+ except Exception as e:
+ print(f" Error: {e}")
+ print()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] != "--interactive":
+ # Try pyshark for pcap files
+ try:
+ import pyshark
+ cap = pyshark.FileCapture(sys.argv[1], display_filter="usb.transfer_type == 0x03")
+ for pkt in cap:
+ try:
+ data = bytes.fromhex(pkt.data.usb_capdata.replace(":", ""))
+ direction = "TX" if hasattr(pkt, "usb") and pkt.usb.endpoint_address_direction == "0" else "RX"
+ decoded = decode_tecan_packet(data, direction)
+ print(f" {float(pkt.sniff_timestamp):.3f} {decoded}")
+ except Exception:
+ continue
+ except ImportError:
+ print("pyshark not installed. Use interactive mode:")
+ print(f" python {sys.argv[0]} --interactive")
+ print(f"\nOr install: pip install pyshark")
+ else:
+ interactive()
diff --git a/keyser-testing/demo_multidrop.py b/keyser-testing/demo_multidrop.py
new file mode 100644
index 00000000000..3b7b59d62c6
--- /dev/null
+++ b/keyser-testing/demo_multidrop.py
@@ -0,0 +1,164 @@
+"""Demo script for the Multidrop Combi bulk dispenser.
+
+Usage:
+ python demo_multidrop.py COM3 # specify port explicitly (recommended)
+ python demo_multidrop.py # auto-detect by USB VID/PID (native USB only)
+"""
+
+import asyncio
+import sys
+
+from pylabrobot.bulk_dispensers import BulkDispenser
+from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import (
+ CassetteType,
+ DispensingOrder,
+ MultidropCombiBackend,
+ MultidropCombiInstrumentError,
+ plate_to_pla_params,
+ plate_to_type_index,
+)
+from pylabrobot.resources.eppendorf.plates import Eppendorf_96_wellplate_250ul_Vb
+
+
+def list_serial_ports():
+ """List available serial ports to help the user find the right one."""
+ try:
+ import serial.tools.list_ports
+
+ ports = list(serial.tools.list_ports.comports())
+ if not ports:
+ print(" No serial ports found.")
+ else:
+ print(" Available ports:")
+ for p in ports:
+ print(f" {p.device} - {p.description} (hwid: {p.hwid})")
+ except ImportError:
+ print(" (pyserial not installed, cannot list ports)")
+
+
+async def run_step(name: str, coro):
+ """Run an async operation with error handling. Returns True on success."""
+ try:
+ await coro
+ print(f" {name}: OK")
+ return True
+ except MultidropCombiInstrumentError as e:
+ print(f" {name}: INSTRUMENT ERROR (status {e.status_code}): {e.description}")
+ return False
+ except Exception as e:
+ print(f" {name}: ERROR: {type(e).__name__}: {e}")
+ return False
+
+
+async def main():
+ port = sys.argv[1] if len(sys.argv) > 1 else None
+
+ if port is None:
+ print("No COM port specified. Attempting VID/PID auto-discovery...")
+ print("(This only works with native USB, not RS232-to-USB adapters)\n")
+
+ # --- Create and connect ---
+ backend = MultidropCombiBackend(port=port, timeout=30.0)
+ dispenser = BulkDispenser(backend=backend)
+
+ try:
+ await dispenser.setup()
+ except Exception as e:
+ print(f"Connection failed: {e}\n")
+ list_serial_ports()
+ print(f"\nUsage: python {sys.argv[0]} ")
+ return
+
+ try:
+ # Connection info
+ info = backend.get_version()
+ print(
+ f"Connected: {info['instrument_name']} "
+ f"FW {info['firmware_version']} SN {info['serial_number']}"
+ )
+
+ # --- Query instrument parameters ---
+ print("\n--- Instrument Parameters ---")
+ try:
+ params = await backend.report_parameters()
+ for line in params[:10]:
+ print(f" {line}")
+ if len(params) > 10:
+ print(f" ... ({len(params)} lines total)")
+ except Exception as e:
+ print(f" REP query failed: {type(e).__name__}: {e}")
+
+ # --- Configure using Eppendorf twin.tec 96-well plate ---
+ print("\n--- Plate Configuration ---")
+ plate = Eppendorf_96_wellplate_250ul_Vb("demo_plate")
+ print(f" Plate: {plate.model}")
+ print(f" Wells: {plate.num_items} ({plate.num_items_y}x{plate.num_items_x})")
+ print(f" Height: {plate.get_size_z()} mm")
+
+ # Map to factory type, fall back to PLA remote definition
+ try:
+ type_idx = plate_to_type_index(plate)
+ print(f" Matched factory plate type: {type_idx}")
+ await run_step("Set plate type (SPL)", dispenser.set_plate_type(plate_type=type_idx))
+ except ValueError:
+ pla_params = plate_to_pla_params(plate)
+ print(f" No factory match, using remote plate definition: {pla_params}")
+ await run_step("Define plate (PLA)", backend.define_plate(**pla_params))
+
+ await run_step(
+ "Set cassette type (SCT)", dispenser.set_cassette_type(cassette_type=CassetteType.STANDARD)
+ )
+ await run_step(
+ "Set column volume 10 uL (SCV)", dispenser.set_column_volume(column=0, volume=10.0)
+ )
+
+ # Dispensing height must be above the plate. Add 3mm clearance.
+ dispense_height = round(plate.get_size_z() * 100) + 300
+ dispense_height = max(500, min(5500, dispense_height)) # clamp to valid range
+ print(f" Dispensing height: {dispense_height} (plate {plate.get_size_z()}mm + 3mm clearance)")
+ await run_step(
+ f"Set dispensing height {dispense_height} (SDH)",
+ dispenser.set_dispensing_height(height=dispense_height),
+ )
+ await run_step("Set pump speed 50% (SPS)", dispenser.set_pump_speed(speed=50))
+ await run_step(
+ "Set dispensing order row-wise (SDO)",
+ dispenser.set_dispensing_order(order=DispensingOrder.ROW_WISE),
+ )
+
+ # --- Prime ---
+ print("\n--- Prime ---")
+ input(" Press Enter to prime (500 uL)...")
+ await run_step("Prime 500 uL (PRI)", dispenser.prime(volume=500.0))
+
+ # --- Dispense ---
+ print("\n--- Dispense ---")
+ input(" Press Enter to dispense...")
+ await run_step("Dispense (DIS)", dispenser.dispense())
+
+ # --- Shake ---
+ print("\n--- Shake ---")
+ input(" Press Enter to shake (3s, 2mm, 10Hz)...")
+ await run_step("Shake (SHA)", dispenser.shake(time=3.0, distance=2, speed=10))
+
+ # --- Move plate out ---
+ print("\n--- Move Plate Out ---")
+ input(" Press Enter to move plate out...")
+ await run_step("Move plate out (POU)", dispenser.move_plate_out())
+
+ # --- Empty ---
+ print("\n--- Empty ---")
+ input(" Press Enter to empty hoses (500 uL)...")
+ await run_step("Empty 500 uL (EMP)", dispenser.empty(volume=500.0))
+
+ print("\n--- Done! Disconnecting. ---")
+
+ finally:
+ try:
+ await dispenser.stop()
+ except Exception as e:
+ print(f" Disconnect error: {e}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/demo_tecan_evo.py b/keyser-testing/demo_tecan_evo.py
new file mode 100644
index 00000000000..7cf647babdc
--- /dev/null
+++ b/keyser-testing/demo_tecan_evo.py
@@ -0,0 +1,411 @@
+"""Tecan EVO 150 connection test script.
+
+Tests USB connection, initialization, and basic queries.
+Includes multiple init strategies to diagnose and work around LiHa Z-axis init failures.
+
+Known issue: LiHa Z-axis init (PIZ/PIA) fails inconsistently.
+EVOware succeeds, suggesting a sequencing/state difference.
+
+Prerequisites:
+ pip install -e ".[usb]"
+
+Usage:
+ python keyser-testing/demo_tecan_evo.py
+"""
+
+import asyncio
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import EVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+
+ERROR_NAMES = {
+ 0: "OK",
+ 1: "Init failed",
+ 2: "Invalid command",
+ 3: "Invalid operand",
+ 5: "Not implemented",
+ 6: "CAN timeout",
+ 7: "Not initialized",
+ 10: "Drive no load",
+}
+
+
+def decode_ree(error_string: str, config_string: str) -> dict[int, int]:
+ """Decode REE codes. Returns {z_tip_number: error_code} for Z-axes."""
+ z_results = {}
+ for i, (axis_char, err_char) in enumerate(zip(config_string, error_string)):
+ code = ord(err_char) - 0x40
+ desc = ERROR_NAMES.get(code, f"Unknown({code})")
+ status = "OK" if code == 0 else f"ERR {code}: {desc}"
+ label = f"{axis_char}{i-2}" if axis_char == "Z" else axis_char
+ print(f" {label} = {status}")
+ if axis_char == "Z":
+ z_results[i - 2] = code
+ return z_results
+
+
+async def get_ree(backend: EVOBackend) -> tuple[str, str]:
+ """Get REE error and config strings."""
+ err = cfg = ""
+ try:
+ resp = await backend.send_command("C5", command="REE", params=[0])
+ err = resp["data"][0] if resp["data"] else ""
+ except Exception:
+ pass
+ try:
+ resp = await backend.send_command("C5", command="REE", params=[1])
+ cfg = resp["data"][0] if resp["data"] else ""
+ except Exception:
+ pass
+ return err, cfg
+
+
+async def show_status(backend: EVOBackend):
+ """Print per-axis status."""
+ err, cfg = await get_ree(backend)
+ if err and cfg:
+ print(" Axis status:")
+ decode_ree(err, cfg)
+
+
+async def query_z_params(backend: EVOBackend):
+ """Query Z-axis configuration for debugging."""
+ print("\n --- Z-Axis Parameters ---")
+ queries = [
+ ("RYB", [], "Y-backlash / Z-overdrive / PWM limit"),
+ ("RVZ", [0], "Z-axis current positions"),
+ ("RGZ", [0], "Global Z-axis values"),
+ ("RDZ", [0], "Z-axis move counters"),
+ ("RDZ", [2], "Z-axis crash counters"),
+ ]
+ for cmd, params, desc in queries:
+ try:
+ resp = await backend.send_command("C5", command=cmd, params=params)
+ print(f" {cmd} {params}: {resp['data']} ({desc})")
+ except Exception as e:
+ print(f" {cmd} {params}: {e}")
+
+
+async def try_init_strategy_srs_then_pia(backend: EVOBackend) -> bool:
+ """Strategy: Full system reset (SRS) then PIA.
+ SRS resets the DCU to power-on state — this is what EVOware likely does."""
+ print("\n Strategy: SRS (system reset) → PIA")
+ print(" SRS resets firmware to power-on state (like EVOware connecting).")
+ input(" Press Enter (robot will reset and re-init)...")
+
+ print(" Sending SRS (system reset)...")
+ try:
+ await backend.send_command("C5", command="SRS")
+ except Exception as e:
+ print(f" SRS response: {e}")
+ # SRS may not return a normal response since the device resets
+
+ # Give the firmware time to reboot
+ print(" Waiting 5s for firmware reboot...")
+ await asyncio.sleep(5)
+
+ print(" Sending PIA...")
+ try:
+ await backend.send_command("C5", command="PIA")
+ print(" PIA: OK!")
+ return True
+ except TecanError as e:
+ print(f" PIA FAILED: error {e.error_code} - {e.message}")
+ await show_status(backend)
+ return False
+
+
+async def try_init_strategy_broadcast_reset(backend: EVOBackend) -> bool:
+ """Strategy: SBC 0 (broadcast SW reset to all subdevices) then PIA."""
+ print("\n Strategy: SBC 0 (broadcast reset) → PIA")
+ input(" Press Enter...")
+
+ print(" Sending SBC 0 (SW reset all subdevices)...")
+ try:
+ await backend.send_command("C5", command="SBC", params=[0])
+ except Exception as e:
+ print(f" SBC response: {e}")
+
+ print(" Waiting 3s for subdevice reset...")
+ await asyncio.sleep(3)
+
+ print(" Sending PIA...")
+ try:
+ await backend.send_command("C5", command="PIA")
+ print(" PIA: OK!")
+ return True
+ except TecanError as e:
+ print(f" PIA FAILED: error {e.error_code} - {e.message}")
+ await show_status(backend)
+ return False
+
+
+async def try_init_strategy_pertip(backend: EVOBackend) -> bool:
+ """Strategy: Init each Z individually with retries, then Y, then X.
+
+ For each Z-axis, tries multiple speeds with multiple attempts per speed.
+ A short delay between retries gives the drive time to settle.
+ """
+ print("\n Strategy: Per-tip Z init (with retries) → PIY → PIX")
+ input(" Press Enter...")
+
+ MAX_ROUNDS = 3 # retry the full speed ladder this many times
+ SPEEDS = [270, 150, 80, 50] # 27, 15, 8, 5 mm/s
+
+ ok_tips = []
+ failed_tips = list(range(1, 9)) # start with all pending
+
+ for round_num in range(1, MAX_ROUNDS + 1):
+ if not failed_tips:
+ break
+ if round_num > 1:
+ print(f"\n --- Retry round {round_num} for Z{failed_tips} ---")
+ await asyncio.sleep(1) # settle time
+
+ still_failed = []
+ for tip in failed_tips:
+ mask = 1 << (tip - 1)
+ success = False
+ for speed in SPEEDS:
+ try:
+ await backend.send_command("C5", command="PIZ", params=[mask, speed])
+ print(f" Z{tip}: OK (speed={speed/10}mm/s, round {round_num})")
+ ok_tips.append(tip)
+ success = True
+ break
+ except TecanError:
+ await asyncio.sleep(0.5) # brief pause between speed attempts
+ continue
+ if not success:
+ still_failed.append(tip)
+ failed_tips = still_failed
+
+ if failed_tips:
+ print(f" Still failed after {MAX_ROUNDS} rounds: Z{failed_tips}")
+
+ for cmd, name in [("PIY", "Y"), ("PIX", "X")]:
+ try:
+ await backend.send_command("C5", command=cmd)
+ print(f" {name}: OK")
+ except TecanError as e:
+ print(f" {name}: FAILED ({e.message})")
+
+ print(f"\n Result: {len(ok_tips)}/8 Z-axes OK")
+ if failed_tips:
+ print(f" Failed: Z{failed_tips}")
+ choice = input(" Use PIF to fake-init failed axes? (y/n): ").strip().lower()
+ if choice == "y":
+ try:
+ await backend.send_command("C5", command="PIF")
+ print(" PIF: OK (all axes marked initialized)")
+ except TecanError as e:
+ print(f" PIF: {e.message}")
+
+ await show_status(backend)
+ return len(failed_tips) == 0
+
+
+async def try_init_strategy_srs_pertip(backend: EVOBackend) -> bool:
+ """Strategy: SRS reset, then per-tip Z init."""
+ print("\n Strategy: SRS → per-tip Z init → PIY → PIX")
+ print(" Combines system reset with individual Z init.")
+ input(" Press Enter...")
+
+ print(" Sending SRS...")
+ try:
+ await backend.send_command("C5", command="SRS")
+ except Exception:
+ pass
+
+ print(" Waiting 5s for reboot...")
+ await asyncio.sleep(5)
+
+ return await try_init_strategy_pertip(backend)
+
+
+async def main():
+ print("=" * 60)
+ print(" Tecan EVO 150 - Init Diagnostic")
+ print("=" * 60)
+
+ backend = EVOBackend(diti_count=0, read_timeout=120, write_timeout=120)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ # --- USB Connection ---
+ print("\n--- USB Connection ---")
+ try:
+ await backend.io.setup()
+ print(" Connected!")
+ except Exception as e:
+ print(f" Failed: {e}")
+ return
+
+ try:
+ # --- Firmware Info ---
+ print("\n--- Firmware ---")
+ for sel, label in [(0, "Version"), (2, "Serial")]:
+ try:
+ resp = await backend.send_command("C5", command="RFV", params=[sel])
+ print(f" LiHa {label}: {resp['data']}")
+ except Exception as e:
+ print(f" {label}: {e}")
+
+ try:
+ resp = await backend.send_command("C5", command="RNT")
+ print(f" Tips: {resp['data']}")
+ except Exception as e:
+ print(f" Tips: {e}")
+
+ print("\n Current state:")
+ await show_status(backend)
+ await query_z_params(backend)
+
+ # --- Init Menu ---
+ print("\n--- LiHa Init ---")
+ print(" 1) EVOware sequence (T23SDO11,1 → PIA) [RECOMMENDED]")
+ print(" 2) PIA only (standard)")
+ print(" 3) SRS system reset → EVOware sequence")
+ print(" 4) Per-tip Z init with retries → PIY → PIX")
+ print(" 5) SRS → per-tip Z init (combines 3 + 4)")
+ print(" 6) PIF (fake init, no movement)")
+ print(" 7) Skip LiHa, just query params")
+ choice = input(" Choice [1-7]: ").strip()
+
+ if choice == "1":
+ print(" Running full EVOware init sequence...")
+ print(" Step 1: TeCU init (M0/M1)")
+ print(" Step 2: Safety module (O1) — enable locks and motor power")
+ print(" Step 3: ZaapMotion config (T23SDO11,1)")
+ print(" Step 4: PIA (init all axes)")
+ input(" Press Enter (robot will move)...")
+
+ # TeCU initialization (M0 = USB interface, M1 = TeCU controller)
+ tecu_cmds = [
+ ("M0", "@RO", [], "USB/TeCU reset"),
+ ("M1", "_Cache_on", [], "Enable TeCU caching"),
+ ]
+ for module, cmd, params, desc in tecu_cmds:
+ try:
+ await backend.send_command(module, command=cmd, params=params)
+ print(f" {module},{cmd}: OK ({desc})")
+ except Exception as e:
+ print(f" {module},{cmd}: {e} ({desc})")
+
+ # Safety module commands (O1 = SAFY firmware)
+ safety_cmds = [
+ ("O1", "ALO", [1, 1], "Activate lock-out 1"),
+ ("O1", "ALO", [2, 1], "Activate lock-out 2"),
+ ("O1", "SSL", [1, 1], "Set safety level 1"),
+ ("O1", "SPN", [], "Power on"),
+ ("O1", "SPS", [3], "Set power state 3"),
+ ]
+ for module, cmd, params, desc in safety_cmds:
+ try:
+ await backend.send_command(module, command=cmd, params=params)
+ print(f" {module},{cmd}: OK ({desc})")
+ except TecanError as e:
+ print(f" {module},{cmd}: error {e.error_code} ({desc})")
+
+ # ZaapMotion subdevice config
+ try:
+ await backend.send_command("C5", command="T23SDO11,1")
+ print(" C5,T23SDO11,1: OK (ZaapMotion config)")
+ except TecanError as e:
+ print(f" C5,T23SDO11,1: error {e.error_code}")
+
+ # PIA
+ print(" Sending PIA...")
+ try:
+ await backend.send_command("C5", command="PIA")
+ print(" PIA: OK — all axes initialized!")
+ except TecanError as e:
+ print(f" PIA FAILED: {e.error_code} - {e.message}")
+ await show_status(backend)
+
+ elif choice == "2":
+ print(" Running PIA...")
+ input(" Press Enter...")
+ try:
+ await backend.send_command("C5", command="PIA")
+ print(" PIA: OK!")
+ except TecanError as e:
+ print(f" PIA FAILED: {e.error_code} - {e.message}")
+ await show_status(backend)
+
+ elif choice == "3":
+ print(" SRS + EVOware sequence...")
+ input(" Press Enter...")
+ try:
+ await backend.send_command("C5", command="SRS")
+ except Exception:
+ pass
+ print(" Waiting 5s for reboot...")
+ await asyncio.sleep(5)
+ try:
+ await backend.send_command("C5", command="T23SDO11,1")
+ print(" T23SDO11,1: OK")
+ except TecanError as e:
+ print(f" T23SDO11,1: error {e.error_code} - {e.message}")
+ try:
+ await backend.send_command("C5", command="PIA")
+ print(" PIA: OK!")
+ except TecanError as e:
+ print(f" PIA FAILED: {e.error_code} - {e.message}")
+ await show_status(backend)
+
+ elif choice == "4":
+ await try_init_strategy_pertip(backend)
+
+ elif choice == "5":
+ await try_init_strategy_srs_pertip(backend)
+
+ elif choice == "6":
+ try:
+ await backend.send_command("C5", command="PIF")
+ print(" PIF: OK")
+ except TecanError as e:
+ print(f" PIF: {e.message}")
+
+ elif choice == "7":
+ pass
+
+ # --- RoMa ---
+ print("\n--- RoMa ---")
+ choice = input(" Initialize RoMa? (y/n): ").strip().lower()
+ if choice == "y":
+ try:
+ ok = await backend.setup_arm(EVOBackend.ROMA)
+ print(f" RoMa: {'OK' if ok else 'not present'}")
+ except TecanError as e:
+ print(f" RoMa FAILED: {e.error_code} ({e.message})")
+
+ # --- MCA ---
+ print("\n--- MCA ---")
+ choice = input(" Initialize MCA? (y/n): ").strip().lower()
+ if choice == "y":
+ try:
+ ok = await backend.setup_arm(EVOBackend.MCA)
+ print(f" MCA: {'OK' if ok else 'not present'}")
+ except TecanError as e:
+ print(f" MCA FAILED: {e.error_code} ({e.message})")
+
+ # --- Final state ---
+ print("\n--- Final State ---")
+ await show_status(backend)
+
+ print("\n--- Done ---")
+
+ finally:
+ print(" Disconnecting...")
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/generate_liquid_classes.py b/keyser-testing/generate_liquid_classes.py
new file mode 100644
index 00000000000..988daeebc1f
--- /dev/null
+++ b/keyser-testing/generate_liquid_classes.py
@@ -0,0 +1,130 @@
+"""Generate TecanLiquidClass mapping entries for ZaapDiTi from DefaultLCs.XML."""
+import re
+import xml.etree.ElementTree as ET
+
+with open("keyser-testing/multidispense pro/DefaultLCs.XML", "r", encoding="utf-8") as f:
+ content = f.read()
+content = re.sub(
+ r"([0-9a-fA-F]+);",
+ lambda m: chr(int(m.group(1), 16)) if int(m.group(1), 16) > 31 else "",
+ content,
+)
+root = ET.fromstring(content)
+
+target_lcs = [
+ "Water free dispense DiTi 50",
+ "Water wet contact DiTi 50",
+ "DMSO free dispense DiTi 50",
+ "DMSO wet contact DiTi 50",
+]
+
+
+def g(elem, attr, default=0):
+ if elem is None:
+ return default
+ v = elem.get(attr)
+ if v is None:
+ return default
+ if v in ("True", "true"):
+ return True
+ if v in ("False", "false"):
+ return False
+ try:
+ return float(v)
+ except ValueError:
+ return default
+
+
+for lc in root.findall("LiquidClass"):
+ name = lc.get("name", "")
+ if name not in target_lcs:
+ continue
+
+ liquid = "Liquid.WATER" if "Water" in name else "Liquid.DMSO"
+
+ glob = lc.find("Global")
+ lld_elem = glob.find("LLD") if glob is not None else None
+ clot_elem = glob.find("Clot") if glob is not None else None
+ pmp_elem = glob.find("PMP") if glob is not None else None
+ lac_elem = glob.find("LAC") if glob is not None else None
+
+ for sc in lc.findall(".//SubClass"):
+ if sc.get("tipType") != "ZaapDiTi":
+ continue
+
+ mn = float(sc.get("min", 0))
+ mx = float(sc.get("max", 0))
+
+ asp = sc.find("Aspirate")
+ asp_s = asp.find("Single") if asp is not None else None
+ asp_lag = asp.find(".//LAG") if asp is not None else None
+ asp_tag = asp.find(".//TAG") if asp is not None else None
+ asp_stag = asp.find(".//STAG") if asp is not None else None
+ asp_exc = asp.find(".//Excess") if asp is not None else None
+ asp_cond = asp.find(".//Conditioning") if asp is not None else None
+ asp_lld = asp.find("LLD") if asp is not None else None
+ asp_mix = asp.find("Mix") if asp is not None else None
+ asp_ret = asp.find("Retract") if asp is not None else None
+
+ disp = sc.find("Dispense")
+ disp_s = disp.find("Single") if disp is not None else None
+ disp_lld = disp.find("LLD") if disp is not None else None
+ disp_touch = disp.find("TipTouching") if disp is not None else None
+ disp_mix = disp.find("Mix") if disp is not None else None
+ disp_ret = disp.find("Retract") if disp is not None else None
+
+ cal = sc.find("Calibration")
+ cal_s = cal.find("Single") if cal is not None else None
+
+ print(f"mapping[({mn}, {mx}, {liquid}, TipType.AIRDITI)] = TecanLiquidClass(")
+ print(f" lld_mode={int(g(lld_elem, 'mode', 7))},")
+ print(f" lld_conductivity={int(g(lld_elem, 'conductivity', 2))},")
+ print(f" lld_speed={g(lld_elem, 'speed', 60)},")
+ print(f" lld_distance={g(lld_elem, 'doubleDist', 4)},")
+ print(f" clot_speed={g(clot_elem, 'speed', 50)},")
+ print(f" clot_limit={g(clot_elem, 'limit', 4)},")
+ print(f" pmp_sensitivity={int(g(pmp_elem, 'sensitivity', 1))},")
+ print(f" pmp_viscosity={g(pmp_elem, 'viscosity', 1)},")
+ print(f" pmp_character={int(g(pmp_elem, 'character', 0))},")
+ print(f" density={g(lac_elem, 'density', 1)},")
+ print(f" calibration_factor={g(cal_s, 'factor', 1)},")
+ print(f" calibration_offset={g(cal_s, 'offset', 0)},")
+ print(f" aspirate_speed={g(asp_s, 'speed', 50)},")
+ print(f" aspirate_delay={g(asp_s, 'delay', 200)},")
+ print(f" aspirate_stag_volume={g(asp_stag, 'volume', 0)},")
+ print(f" aspirate_stag_speed={g(asp_stag, 'speed', 20)},")
+ print(f" aspirate_lag_volume={g(asp_lag, 'volume', 10)},")
+ print(f" aspirate_lag_speed={g(asp_lag, 'speed', 70)},")
+ print(f" aspirate_tag_volume={g(asp_tag, 'volume', 5)},")
+ print(f" aspirate_tag_speed={g(asp_tag, 'speed', 20)},")
+ print(f" aspirate_excess={g(asp_exc, 'volume', 0)},")
+ print(f" aspirate_conditioning={g(asp_cond, 'volume', 0)},")
+ print(f" aspirate_pinch_valve={g(asp_s, 'pinchValve', False)},")
+ print(f" aspirate_lld={g(asp_lld, 'detect', True)},")
+ print(f" aspirate_lld_position={int(g(asp_lld, 'position', 3))},")
+ print(f" aspirate_lld_offset={g(asp_lld, 'offset', 0)},")
+ print(f" aspirate_mix={g(asp_mix, 'enabled', False)},")
+ print(f" aspirate_mix_volume={g(asp_mix, 'volume', 100)},")
+ print(f" aspirate_mix_cycles={int(g(asp_mix, 'cycles', 1))},")
+ print(f" aspirate_retract_position={int(g(asp_ret, 'position', 4))},")
+ print(f" aspirate_retract_speed={g(asp_ret, 'speed', 5)},")
+ print(f" aspirate_retract_offset={g(asp_ret, 'offset', -5)},")
+ print(f" dispense_speed={g(disp_s, 'speed', 600)},")
+ print(f" dispense_breakoff={g(disp_s, 'breakoff', 400)},")
+ print(f" dispense_delay={g(disp_s, 'delay', 0)},")
+ print(f" dispense_tag={g(disp_s, 'tag', False)},")
+ print(f" dispense_pinch_valve={g(disp_s, 'pinchValve', False)},")
+ print(f" dispense_lld={g(disp_lld, 'detect', False)},")
+ print(f" dispense_lld_position={int(g(disp_lld, 'position', 7))},")
+ print(f" dispense_lld_offset={g(disp_lld, 'offset', 0)},")
+ print(f" dispense_touching_direction={int(g(disp_touch, 'direction', 0))},")
+ print(f" dispense_touching_speed={g(disp_touch, 'speed', 10)},")
+ print(f" dispense_touching_delay={g(disp_touch, 'delay', 100)},")
+ print(f" dispense_mix={g(disp_mix, 'enabled', False)},")
+ print(f" dispense_mix_volume={g(disp_mix, 'volume', 100)},")
+ print(f" dispense_mix_cycles={int(g(disp_mix, 'cycles', 1))},")
+ print(f" dispense_retract_position={int(g(disp_ret, 'position', 1))},")
+ print(f" dispense_retract_speed={g(disp_ret, 'speed', 50)},")
+ print(f" dispense_retract_offset={g(disp_ret, 'offset', 0)},")
+ print(f") # {name} [{mn}-{mx} uL]")
+ print()
diff --git a/keyser-testing/jog_liha.py b/keyser-testing/jog_liha.py
new file mode 100644
index 00000000000..57c26b8bffe
--- /dev/null
+++ b/keyser-testing/jog_liha.py
@@ -0,0 +1,165 @@
+"""Interactive jog tool for Tecan EVO Air LiHa.
+
+Move the LiHa channels in X, Y, Z using keyboard commands.
+Record positions for use in labware definitions.
+
+Commands:
+ x+/x- Move X by step size (mm)
+ y+/y- Move Y by step size
+ z+/z- Move Z by step size (z+ = toward deck, z- = away from deck)
+ s1/s5/s10/s50 Set step size (mm)
+ p Print current position (absolute Tecan coordinates)
+ r Record current position with a label
+ h Home (move to init position)
+ q Quit
+
+Usage:
+ python keyser-testing/jog_liha.py
+"""
+
+import asyncio
+import logging
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import AirEVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+logging.basicConfig(level=logging.WARNING)
+
+POSITIONS_FILE = os.path.join(os.path.dirname(__file__), "taught_positions.json")
+
+
+async def get_position(backend):
+ """Read current X, Y, Z positions from the LiHa."""
+ resp_x = await backend.send_command("C5", command="RPX0")
+ resp_y = await backend.send_command("C5", command="RPY0")
+ resp_z = await backend.send_command("C5", command="RPZ0")
+ x = resp_x["data"][0] if resp_x and resp_x.get("data") else 0
+ y = resp_y["data"][0] if resp_y and resp_y.get("data") else 0
+ # Y has two values (Y and Yspace)
+ if isinstance(y, list):
+ y = y[0]
+ z_vals = resp_z["data"] if resp_z and resp_z.get("data") else [0]
+ return x, y, z_vals
+
+
+async def main():
+ print("=" * 60)
+ print(" Tecan EVO Air LiHa Jog Tool")
+ print("=" * 60)
+
+ backend = AirEVOBackend(diti_count=8)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ print("\nInitializing...")
+ try:
+ await lh.setup()
+ except Exception as e:
+ print(f"Init failed: {e}")
+ return
+
+ step = 5.0 # mm
+ recorded = {}
+
+ try:
+ x, y, z_vals = await get_position(backend)
+ print(f"\nCurrent position: X={x} Y={y} Z={z_vals} (1/10mm)")
+ print(f"Step size: {step}mm")
+ print()
+ print("Commands: x+/x- y+/y- z+/z- s1/s5/s10/s50 p r h q")
+ print(" z+ = toward deck, z- = away from deck")
+ print()
+
+ while True:
+ cmd = input("jog> ").strip().lower()
+
+ if cmd == "q":
+ break
+
+ elif cmd == "p":
+ x, y, z_vals = await get_position(backend)
+ print(f" X={x} Y={y} Z={z_vals} (1/10mm)")
+ print(f" X={x/10:.1f}mm Y={y/10:.1f}mm Z1={z_vals[0]/10:.1f}mm")
+
+ elif cmd == "r":
+ x, y, z_vals = await get_position(backend)
+ label = input(" Label for this position: ").strip()
+ if label:
+ recorded[label] = {"x": x, "y": y, "z": z_vals, "step_mm": step}
+ print(f" Recorded '{label}': X={x} Y={y} Z={z_vals}")
+ with open(POSITIONS_FILE, "w") as f:
+ json.dump(recorded, f, indent=2)
+ print(f" Saved to {POSITIONS_FILE}")
+
+ elif cmd == "h":
+ print(" Homing...")
+ await backend.liha.set_z_travel_height([backend._z_range] * backend.num_channels)
+ await backend.liha.position_absolute_all_axis(
+ 45, 1031, 90, [backend._z_range] * backend.num_channels
+ )
+ x, y, z_vals = await get_position(backend)
+ print(f" Position: X={x} Y={y} Z={z_vals}")
+
+ elif cmd.startswith("s"):
+ try:
+ step = float(cmd[1:])
+ print(f" Step size: {step}mm")
+ except ValueError:
+ print(" Usage: s1, s5, s10, s50")
+
+ elif cmd in ("x+", "x-"):
+ delta = int(step * 10) if cmd == "x+" else -int(step * 10)
+ try:
+ await backend.send_command("C5", command=f"PRX{delta}")
+ x, y, z_vals = await get_position(backend)
+ print(f" X={x} ({x/10:.1f}mm)")
+ except TecanError as e:
+ print(f" Move failed: {e}")
+
+ elif cmd in ("y+", "y-"):
+ delta = int(step * 10) if cmd == "y+" else -int(step * 10)
+ try:
+ await backend.send_command("C5", command=f"PRY{delta}")
+ x, y, z_vals = await get_position(backend)
+ print(f" Y={y} ({y/10:.1f}mm)")
+ except TecanError as e:
+ print(f" Move failed: {e}")
+
+ elif cmd in ("z+", "z-"):
+ # z+ = toward deck (increase Z value), z- = away from deck
+ delta = int(step * 10) if cmd == "z+" else -int(step * 10)
+ # Move all Z axes together
+ z_params = ",".join([str(delta)] * backend.num_channels)
+ try:
+ await backend.send_command("C5", command=f"PRZ{z_params}")
+ x, y, z_vals = await get_position(backend)
+ print(f" Z={z_vals} (Z1={z_vals[0]/10:.1f}mm)")
+ except TecanError as e:
+ print(f" Move failed: {e}")
+
+ elif cmd == "":
+ continue
+
+ else:
+ print(" Commands: x+/x- y+/y- z+/z- s1/s5/s10/s50 p r h q")
+
+ finally:
+ print("\nStopping...")
+ await lh.stop()
+ print("Done.")
+
+ if recorded:
+ print(f"\nRecorded positions saved to {POSITIONS_FILE}:")
+ for label, pos in recorded.items():
+ print(f" {label}: X={pos['x']} Y={pos['y']} Z={pos['z']}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/labware_library.py b/keyser-testing/labware_library.py
new file mode 100644
index 00000000000..f4b3709d1bd
--- /dev/null
+++ b/keyser-testing/labware_library.py
@@ -0,0 +1,136 @@
+"""Custom labware definitions for our lab.
+
+These are modified versions of standard plates/tips that match our
+specific hardware configuration.
+"""
+
+from typing import Optional
+
+from pylabrobot.resources import CrossSectionType, Well
+from pylabrobot.resources.tecan.plates import TecanPlate
+from pylabrobot.resources.tecan.tip_creators import TecanTip, TipType
+from pylabrobot.resources.tecan.tip_racks import TecanTipRack
+from pylabrobot.resources.tip_rack import TipSpot
+from pylabrobot.resources.utils import create_ordered_items_2d
+from pylabrobot.resources.well import WellBottomType
+
+
+# ============== Plates ==============
+
+
+# Volume/height functions from Eppendorf_96_wellplate_250ul_Vb
+def _compute_volume_from_height(h: float) -> float:
+ if h > 19.5:
+ raise ValueError(f"Height {h} is too large for Eppendorf_96_wellplate_250ul_Vb_skirted")
+ return max(
+ 0.89486648 + 2.92455131 * h + 2.03472797 * h**2 + -0.16509371 * h**3 + 0.00675759 * h**4,
+ 0,
+ )
+
+
+def _compute_height_from_volume(liquid_volume: float) -> float:
+ if liquid_volume > 262.5:
+ raise ValueError(
+ f"Volume {liquid_volume} is too large for Eppendorf_96_wellplate_250ul_Vb_skirted"
+ )
+ return max(
+ 0.118078503
+ + 0.133333914 * liquid_volume
+ + -0.000802726227 * liquid_volume**2
+ + 3.29761957e-06 * liquid_volume**3
+ + -5.29119614e-09 * liquid_volume**4,
+ 0,
+ )
+
+
+def Eppendorf_96_wellplate_250ul_Vb_skirted(name: str) -> TecanPlate:
+ """Eppendorf twin.tec 96-well PCR plate, 250uL, V-bottom.
+
+ TecanPlate version with z-positions for LiHa operations, marked as skirted
+ for compatibility with MP_3Pos carrier PlateHolder.
+
+ Z-positions based on Microplate_96_Well from EVOware defaults.
+
+ Eppendorf cat. no.: 0030133374
+ """
+ return TecanPlate(
+ name=name,
+ size_x=123.0,
+ size_y=81.0,
+ size_z=20.3,
+ lid=None,
+ model="Eppendorf_96_wellplate_250ul_Vb_skirted",
+ z_start=1957.0,
+ z_dispense=1975.0,
+ z_max=2005.0,
+ area=33.2,
+ ordered_items=create_ordered_items_2d(
+ Well,
+ num_items_x=12,
+ num_items_y=8,
+ dx=6.76,
+ dy=8.26,
+ dz=0.0,
+ item_dx=9,
+ item_dy=9,
+ size_x=5.48,
+ size_y=5.48,
+ size_z=19.5,
+ bottom_type=WellBottomType.V,
+ material_z_thickness=1.2,
+ cross_section_type=CrossSectionType.CIRCLE,
+ compute_volume_from_height=_compute_volume_from_height,
+ compute_height_from_volume=_compute_height_from_volume,
+ ),
+ )
+
+
+# ============== Tips ==============
+
+
+def DiTi_50ul_SBS_LiHa_Air_tip(name: Optional[str] = None) -> TecanTip:
+ """DiTi 50uL SBS tip for Air LiHa.
+
+ Tecan part no. 30057813.
+ Tip length 58.1mm measured with calipers.
+ """
+ return TecanTip(
+ name=name,
+ has_filter=False,
+ total_tip_length=58.1,
+ maximal_volume=55.0,
+ tip_type=TipType.AIRDITI,
+ )
+
+
+def DiTi_50ul_SBS_LiHa_Air(name: str) -> TecanTipRack:
+ """DiTi 50uL SBS tip rack for Air LiHa.
+
+ Tecan part no. 30057813. SBS-format tip box (~45mm tall).
+ Based on DiTi_50ul_SBS_LiHa with corrected tip definition
+ (proper total_tip_length and AIRDITI tip type).
+ """
+ return TecanTipRack(
+ name=name,
+ size_x=128.2,
+ size_y=86.0,
+ size_z=45.0,
+ model="DiTi_50ul_SBS_LiHa_Air",
+ z_start=800.0, # taught: tip engagement at Z=770, start search above
+ z_dispense=720.0,
+ z_max=600.0, # search range: 300 units (30mm) below z_start
+ area=33.2,
+ ordered_items=create_ordered_items_2d(
+ TipSpot,
+ num_items_x=12,
+ num_items_y=8,
+ dx=10.1,
+ dy=7.0,
+ dz=-5.3,
+ item_dx=9.0,
+ item_dy=9.0,
+ size_x=9.0,
+ size_y=9.0,
+ make_tip=DiTi_50ul_SBS_LiHa_Air_tip,
+ ),
+ )
diff --git a/keyser-testing/query_zaapmotion.py b/keyser-testing/query_zaapmotion.py
new file mode 100644
index 00000000000..0bfcccc44bc
--- /dev/null
+++ b/keyser-testing/query_zaapmotion.py
@@ -0,0 +1,112 @@
+"""Query ZaapMotion bootloader state and firmware info.
+
+Gathers diagnostic info to understand the ZaapMotion firmware upload requirements.
+
+Usage:
+ python keyser-testing/query_zaapmotion.py
+"""
+
+import asyncio
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import EVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+
+async def send(backend, module, raw_cmd, desc=""):
+ """Send a raw command and print result."""
+ try:
+ resp = await backend.send_command(module, command=raw_cmd)
+ data = resp.get("data", []) if resp else []
+ print(f" > {module},{raw_cmd:35s} -> {data} ({desc})")
+ return data
+ except TecanError as e:
+ print(f" > {module},{raw_cmd:35s} -> ERR {e.error_code}: {e.message} ({desc})")
+ return None
+ except Exception as e:
+ print(f" > {module},{raw_cmd:35s} -> {e} ({desc})")
+ return None
+
+
+async def main():
+ print("=" * 70)
+ print(" ZaapMotion Bootloader Diagnostics")
+ print("=" * 70)
+
+ backend = EVOBackend(diti_count=0, packet_read_timeout=30, read_timeout=120, write_timeout=120)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ try:
+ await backend.io.setup()
+ print(" USB connected!\n")
+ except Exception as e:
+ print(f" USB failed: {e}")
+ return
+
+ try:
+ # --- LiHa firmware info ---
+ print("--- LiHa Firmware ---")
+ await send(backend, "C5", "RFV0", "LiHa firmware version")
+ await send(backend, "C5", "RFV2", "LiHa serial number")
+ await send(backend, "C5", "RFV8", "LiHa app switch")
+ await send(backend, "C5", "RFV15", "LiHa expected HEX filename")
+ await send(backend, "C5", "RFV16", "LiHa expected DC-Servo2 firmware version")
+ await send(backend, "C5", "RFV9", "LiHa node address count")
+
+ # --- ZaapMotion per-tip queries ---
+ # T2x = transparent pipeline, layer 2, device x (0-7 = tips 1-8)
+ print("\n--- ZaapMotion Per-Tip Status ---")
+ for tip in range(8):
+ print(f"\n Tip {tip + 1} (T2{tip}):")
+ await send(backend, "C5", f"T2{tip}RFV0", "Firmware version")
+ await send(backend, "C5", f"T2{tip}RFV2", "Serial number")
+ await send(backend, "C5", f"T2{tip}RFV15", "Expected HEX filename")
+ await send(backend, "C5", f"T2{tip}RFV9", "Node address count")
+
+ # --- Try bootloader-specific commands on tip 0 ---
+ # The LiHa manual says bootloader accepts: A (check filename), S (start), E (erase), H (hex data)
+ # These are single-char commands via the transparent pipeline
+ print("\n--- Bootloader Probe (Tip 1 / T20) ---")
+
+ # Try querying the bootloader with 'A' + a test filename
+ await send(backend, "C5", "T20AXYZ", "Boot: check filename 'XYZ'")
+ await send(backend, "C5", "T20AXPZ", "Boot: check filename 'XPZ'")
+ await send(backend, "C5", "T20AXPM", "Boot: check filename 'XPM'")
+ await send(backend, "C5", "T20AZMA", "Boot: check filename 'ZMA'")
+ await send(backend, "C5", "T20AZMB", "Boot: check filename 'ZMB'")
+ await send(backend, "C5", "T20AZMO", "Boot: check filename 'ZMO'")
+
+ # --- LiHa dilutor report commands ---
+ print("\n--- LiHa Dilutor Reports ---")
+ await send(backend, "C5", "RDA2,0", "Device allocation array")
+ await send(backend, "C5", "RSD", "Report second LiHa")
+ await send(backend, "C5", "RGD3", "Global data 3")
+ await send(backend, "C5", "RGD58", "Global data 58")
+ await send(backend, "C5", "RGD59", "Global data 59")
+
+ # --- Check what EVOware's ZaapMotion scan queries ---
+ # From the EVOware log, it sends T2xRPP7 and T2xRPP10
+ print("\n--- ZaapMotion Config (from EVOware scan) ---")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}RPP7", f"Tip {tip+1}: plunger param 7 (syringe vol)")
+ await send(backend, "C5", f"T2{tip}RPP10", f"Tip {tip+1}: plunger param 10")
+
+ # --- Try the ZaapMotion RMV commands EVOware uses ---
+ print("\n--- ZaapMotion Module Verification ---")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}RMV1", f"Tip {tip+1}: module verify 1")
+
+ print("\n--- Done ---")
+
+ finally:
+ print("\n Disconnecting...")
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/replay_evoware_init.py b/keyser-testing/replay_evoware_init.py
new file mode 100644
index 00000000000..1246bb2e28d
--- /dev/null
+++ b/keyser-testing/replay_evoware_init.py
@@ -0,0 +1,134 @@
+"""Replay the exact EVOware init sequence from the captured log.
+
+This sends every command EVOware sent (to C5, O1, C1) in the same order,
+to determine if pylabrobot's PIA failure is a command/sequencing issue
+or a USB transport issue.
+
+Usage:
+ python keyser-testing/replay_evoware_init.py
+"""
+
+import asyncio
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import EVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+
+async def send(backend, module, raw_cmd, desc=""):
+ """Send a raw command and print result. raw_cmd is everything after the module prefix."""
+ try:
+ resp = await backend.send_command(module, command=raw_cmd)
+ data = resp.get("data", []) if resp else []
+ print(f" > {module},{raw_cmd:30s} -> OK {data} {desc}")
+ return resp
+ except TecanError as e:
+ print(f" > {module},{raw_cmd:30s} -> ERR {e.error_code}: {e.message} {desc}")
+ return None
+ except Exception as e:
+ print(f" > {module},{raw_cmd:30s} -> {e} {desc}")
+ return None
+
+
+async def main():
+ print("=" * 70)
+ print(" Replay EVOware Init Sequence")
+ print("=" * 70)
+
+ backend = EVOBackend(diti_count=0, packet_read_timeout=30, read_timeout=120, write_timeout=120)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ print("\n--- USB Connection ---")
+ try:
+ await backend.io.setup()
+ print(" Connected!")
+ except Exception as e:
+ print(f" Failed: {e}")
+ return
+
+ try:
+ # === Phase 1: Initial queries (EVOware log lines 22-28) ===
+ print("\n--- Phase 1: TeCU Queries ---")
+ await send(backend, "M1", "RFV2", "TeCU serial")
+ await send(backend, "M1", "RFV0", "TeCU firmware")
+ await send(backend, "M1", "RSS", "Subsystem list")
+
+ # === Phase 2: RoMa queries (log lines 52-120) ===
+ print("\n--- Phase 2: RoMa Queries ---")
+ await send(backend, "C1", "RFV", "RoMa firmware")
+ await send(backend, "C1", "REE", "RoMa error state")
+
+ # === Phase 3: LiHa queries (log lines 123-270) ===
+ print("\n--- Phase 3: LiHa Queries ---")
+ await send(backend, "C5", "RFV", "LiHa firmware")
+ await send(backend, "C5", "RNT0", "Tip count (binary)")
+ await send(backend, "C5", "RNT1", "Tip count (decimal)")
+ await send(backend, "C5", "REE", "Error state before init")
+ await send(backend, "C5", "RPZ5", "Z-axis range")
+ await send(backend, "C5", "RPX5", "X-axis range")
+ await send(backend, "C5", "RPY5", "Y-axis range")
+ await send(backend, "C5", "RPZ4", "Z-axis init offsets")
+ await send(backend, "C5", "SFP0", "Set force param 0")
+
+ # ZaapMotion queries (log lines 137-211)
+ print("\n--- Phase 3b: ZaapMotion Queries ---")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}RFV0", f"ZaapMotion tip {tip} firmware")
+
+ # === Phase 4: Safety module setup (log lines 280-298) ===
+ print("\n--- Phase 4: Safety Module Pre-Config ---")
+ await send(backend, "O1", "RFV", "Safety firmware")
+ await send(backend, "O1", "RSL1", "Report safety level 1")
+ await send(backend, "O1", "RSL2", "Report safety level 2")
+ await send(backend, "O1", "RLO1", "Report lock-out 1")
+ await send(backend, "O1", "RLS", "Report lock status")
+ await send(backend, "O1", "SLO1,0", "Set lock-out 1 = 0")
+ await send(backend, "O1", "SLO2,0", "Set lock-out 2 = 0")
+ await send(backend, "O1", "SLO3,0", "Set lock-out 3 = 0")
+ await send(backend, "O1", "SLO4,0", "Set lock-out 4 = 0")
+
+ # === Phase 5: Init trigger (log lines 309-350) ===
+ print("\n--- Phase 5: Init Sequence ---")
+ print(" This is the exact EVOware init sequence.")
+ input(" Press Enter (robot WILL move)...")
+
+ await send(backend, "O1", "ALO1,1", "Activate lock-out 1")
+ await send(backend, "O1", "ALO2,1", "Activate lock-out 2")
+ await send(backend, "O1", "RSL2", "Check safety level 2")
+ await send(backend, "O1", "SSL1,1", "Set safety level 1")
+
+ # Small delay — EVOware has Named Pipe traffic here
+ await asyncio.sleep(0.5)
+
+ await send(backend, "O1", "SPN", "Power on")
+ await send(backend, "O1", "SPS3", "Set power state 3")
+ await send(backend, "C5", "T23SDO11,1", "ZaapMotion config")
+
+ print("\n Sending PIA (this takes ~24 seconds)...")
+ await send(backend, "C5", "PIA", "*** INIT ALL AXES ***")
+
+ # Check result
+ print("\n--- Init Result ---")
+ await send(backend, "C5", "REE0", "Extended error codes")
+ await send(backend, "C5", "REE1", "Axis config")
+
+ # === If PIA succeeded, continue with EVOware post-init ===
+ print("\n--- Post-Init (EVOware sequence) ---")
+ await send(backend, "C5", "BMX2", "Stop X")
+ await send(backend, "C1", "PIA", "RoMa init")
+ await send(backend, "C1", "PIX", "RoMa X-axis init")
+
+ print("\n--- Done ---")
+
+ finally:
+ print("\n Disconnecting...")
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/taught_positions.json b/keyser-testing/taught_positions.json
new file mode 100644
index 00000000000..c073dfcf688
--- /dev/null
+++ b/keyser-testing/taught_positions.json
@@ -0,0 +1,17 @@
+{
+ "tip_load": {
+ "x": 3891,
+ "y": 128,
+ "z": [
+ 770,
+ 770,
+ 770,
+ 770,
+ 770,
+ 770,
+ 770,
+ 770
+ ],
+ "step_mm": 1.0
+ }
+}
\ No newline at end of file
diff --git a/keyser-testing/test_air_evo_init.py b/keyser-testing/test_air_evo_init.py
new file mode 100644
index 00000000000..ccc29d66515
--- /dev/null
+++ b/keyser-testing/test_air_evo_init.py
@@ -0,0 +1,120 @@
+"""Hardware test: AirEVOBackend initialization.
+
+Tests that AirEVOBackend can:
+1. Connect via USB
+2. Exit ZaapMotion boot mode
+3. Configure ZaapMotion motor controllers
+4. Initialize all axes (PIA)
+5. Report correct axis status
+
+Run on a Tecan EVO 150 with Air LiHa after a fresh power cycle.
+
+Usage:
+ python keyser-testing/test_air_evo_init.py
+"""
+
+import asyncio
+import logging
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import AirEVOBackend
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+# Enable logging so we can see what AirEVOBackend is doing
+logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
+
+
+async def main():
+ print("=" * 60)
+ print(" AirEVOBackend Init Test")
+ print("=" * 60)
+
+ backend = AirEVOBackend(diti_count=8)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ print("\nSetting up (ZaapMotion config + PIA)...")
+ print("Robot WILL move.\n")
+
+ try:
+ await lh.setup()
+ print("\nSetup complete!")
+ except Exception as e:
+ print(f"\nSetup FAILED: {type(e).__name__}: {e}")
+ import traceback
+
+ traceback.print_exc()
+
+ # Try to get diagnostics even after failure
+ print("\n--- Post-failure diagnostics ---")
+ try:
+ resp = await backend.send_command("C5", command="REE0")
+ err = resp["data"][0] if resp and resp.get("data") else ""
+ resp = await backend.send_command("C5", command="REE1")
+ cfg = resp["data"][0] if resp and resp.get("data") else ""
+ if err and cfg:
+ error_names = {0: "OK", 1: "Init failed", 7: "Not initialized"}
+ for i, (axis, code_char) in enumerate(zip(cfg, err)):
+ code = ord(code_char) - 0x40
+ label = f"{axis}{i-2}" if axis == "Z" else axis
+ status = "OK" if code == 0 else f"ERR {code}: {error_names.get(code, '?')}"
+ print(f" {label} = {status}")
+ except Exception:
+ pass
+
+ try:
+ for tip in range(8):
+ resp = await backend.send_command("C5", command=f"T2{tip}RFV0")
+ fw = resp["data"][0] if resp and resp.get("data") else "?"
+ print(f" Tip {tip+1}: {fw}")
+ except Exception:
+ pass
+
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+ return
+
+ try:
+ # Verify axis status
+ resp = await backend.send_command("C5", command="REE0")
+ err = resp["data"][0] if resp and resp.get("data") else ""
+ resp = await backend.send_command("C5", command="REE1")
+ cfg = resp["data"][0] if resp and resp.get("data") else ""
+
+ print("\nAxis status:")
+ error_names = {0: "OK", 1: "Init failed", 7: "Not initialized"}
+ all_ok = True
+ for i, (axis, code_char) in enumerate(zip(cfg, err)):
+ code = ord(code_char) - 0x40
+ label = f"{axis}{i-2}" if axis == "Z" else axis
+ status = "OK" if code == 0 else f"ERR {code}: {error_names.get(code, '?')}"
+ print(f" {label} = {status}")
+ if code != 0:
+ all_ok = False
+
+ print(f"\nChannels: {backend.num_channels}")
+ print(f"LiHa: {backend.liha_connected}")
+ print(f"RoMa: {backend.roma_connected}")
+
+ # Verify ZaapMotion is in app mode
+ print("\nZaapMotion firmware:")
+ for tip in range(8):
+ resp = await backend.send_command("C5", command=f"T2{tip}RFV0")
+ fw = resp["data"][0] if resp and resp.get("data") else "?"
+ print(f" Tip {tip+1}: {fw}")
+
+ if all_ok:
+ print("\n*** INIT TEST PASSED ***")
+ else:
+ print("\n*** INIT TEST FAILED — some axes not initialized ***")
+
+ finally:
+ print("\nStopping...")
+ await lh.stop()
+ print("Done.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/test_air_evo_pipette.py b/keyser-testing/test_air_evo_pipette.py
new file mode 100644
index 00000000000..d4896fefc9d
--- /dev/null
+++ b/keyser-testing/test_air_evo_pipette.py
@@ -0,0 +1,119 @@
+"""Hardware test: AirEVOBackend pipetting operations.
+
+Tests pick up tips, aspirate, dispense, and drop tips.
+
+Deck layout:
+ Rail 16: MP_3Pos carrier
+ Position 1: Eppendorf plate (source, water in A1-H1)
+ Position 2: Eppendorf plate (destination, empty)
+ Position 3: DiTi_50ul_SBS_LiHa tip rack
+
+Prerequisites:
+ - EVO powered on with Air LiHa
+ - Eppendorf plate with water in wells A1-H1 in position 1
+ - Empty Eppendorf plate in position 2
+ - DiTi 50uL tips loaded in position 3
+ - pip install -e ".[usb]"
+
+Usage:
+ python keyser-testing/test_air_evo_pipette.py
+"""
+
+import asyncio
+import logging
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+
+from labware_library import DiTi_50ul_SBS_LiHa_Air, Eppendorf_96_wellplate_250ul_Vb_skirted
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import AirEVOBackend
+from pylabrobot.resources.tecan.plate_carriers import MP_3Pos
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
+
+
+async def main():
+ print("=" * 60)
+ print(" AirEVOBackend Pipetting Test")
+ print("=" * 60)
+
+ # --- Deck setup ---
+ backend = AirEVOBackend(diti_count=8)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ # MP_3Pos carrier at rail 16
+ carrier = MP_3Pos("carrier")
+ deck.assign_child_resource(carrier, rails=16)
+
+ # Position 1: source plate, Position 2: dest plate, Position 3: tip rack
+ source_plate = Eppendorf_96_wellplate_250ul_Vb_skirted("source")
+ dest_plate = Eppendorf_96_wellplate_250ul_Vb_skirted("dest")
+ tip_rack = DiTi_50ul_SBS_LiHa_Air("tips")
+ carrier[0] = source_plate
+ carrier[1] = dest_plate
+ carrier[2] = tip_rack
+
+ print("\nDeck layout:")
+ print(" MP_3Pos carrier: rail 16")
+ print(f" Position 1: {source_plate.name} (water in A1-H1)")
+ print(f" Position 2: {dest_plate.name} (empty)")
+ print(f" Position 3: {tip_rack.name} (DiTi 50uL)")
+
+ # Setup handles init-skip automatically (checks REE0, skips PIA if already initialized)
+ print("\nInitializing...")
+ try:
+ await lh.setup()
+ print(f" Channels: {backend.num_channels}, Z-range: {backend._z_range}")
+ print("Ready!")
+ except Exception as e:
+ print(f"Init FAILED: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return
+
+ try:
+ # --- Test 1: Pick up 8 tips ---
+ print("\n--- Test 1: Pick Up Tips ---")
+ input("Press Enter to pick up 8 tips from column 1...")
+ await lh.pick_up_tips(tip_rack.get_items(["A1","B1","C1","D1","E1","F1","G1","H1"]))
+ print("Tips picked up!")
+
+ # --- Test 2: Aspirate ---
+ print("\n--- Test 2: Aspirate 25uL ---")
+ input("Press Enter to aspirate 25uL from source column 1...")
+ await lh.aspirate(source_plate.get_items(["A1","B1","C1","D1","E1","F1","G1","H1"]), vols=[25] * 8)
+ print("Aspirated!")
+
+ # --- Test 3: Dispense ---
+ print("\n--- Test 3: Dispense 25uL ---")
+ input("Press Enter to dispense 25uL to dest column 1...")
+ await lh.dispense(dest_plate.get_items(["A1","B1","C1","D1","E1","F1","G1","H1"]), vols=[25] * 8)
+ print("Dispensed!")
+
+ # --- Test 4: Drop tips ---
+ print("\n--- Test 4: Drop Tips ---")
+ input("Press Enter to drop tips back to tip rack...")
+ await lh.drop_tips(tip_rack.get_items(["A1","B1","C1","D1","E1","F1","G1","H1"]))
+ print("Tips dropped!")
+
+ print("\n*** PIPETTING TEST PASSED ***")
+
+ except Exception as e:
+ print(f"\nTest FAILED: {type(e).__name__}: {e}")
+ import traceback
+
+ traceback.print_exc()
+
+ finally:
+ print("\nStopping...")
+ await lh.stop()
+ print("Done.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/tips_off.py b/keyser-testing/tips_off.py
new file mode 100644
index 00000000000..1ac374daaec
--- /dev/null
+++ b/keyser-testing/tips_off.py
@@ -0,0 +1,98 @@
+"""Emergency tip removal — drops any mounted tips at the tip rack position.
+
+Deck layout must match test_air_evo_pipette.py:
+ Rail 16: MP_3Pos carrier, Position 3: tip rack
+
+Usage:
+ python keyser-testing/tips_off.py
+"""
+
+import asyncio
+import logging
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+
+from labware_library import DiTi_50ul_SBS_LiHa_Air
+from pylabrobot.liquid_handling.backends.tecan import AirEVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.plate_carriers import MP_3Pos
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+logging.basicConfig(level=logging.WARNING)
+
+
+async def main():
+ print("Tips Off — dropping any mounted tips at tip rack position")
+
+ backend = AirEVOBackend(diti_count=8)
+ deck = EVO150Deck()
+
+ # Same deck layout as test script
+ carrier = MP_3Pos("carrier")
+ deck.assign_child_resource(carrier, rails=16)
+ tip_rack = DiTi_50ul_SBS_LiHa_Air("tips")
+ carrier[2] = tip_rack
+
+ saved_prt = backend.io.packet_read_timeout
+ backend.io.packet_read_timeout = 1
+ await backend.io.setup()
+ backend.io.packet_read_timeout = saved_prt
+
+ from pylabrobot.liquid_handling.backends.tecan.EVO_backend import LiHa
+
+ backend.liha = LiHa(backend, "C5")
+ backend._num_channels = await backend.liha.report_number_tips()
+ backend._x_range = await backend.liha.report_x_param(5)
+ backend._y_range = (await backend.liha.report_y_param(5))[0]
+ backend._z_range = (await backend.liha.report_z_param(5))[0]
+ backend.set_deck(deck)
+
+ # Check tip status
+ resp = await backend.send_command("C5", command="RTS")
+ tip_status = resp["data"][0] if resp and resp.get("data") else 0
+ print(f" Tip status (RTS): {tip_status} (0=no tips, 255=all tips)")
+
+ if tip_status == 0:
+ print(" No tips mounted.")
+ else:
+ print(" Tips detected, moving to tip rack and dropping...")
+
+ # Move to tip rack position (same X calc as pick_up_tips with offset)
+ spot = tip_rack.get_item("A1")
+ loc = spot.get_location_wrt(deck) + spot.center()
+ x = int((loc.x - 100) * 10) + 60 # same X offset as pick_up_tips
+ ys = int(spot.get_absolute_size_y() * 10)
+ y = int((346.5 - loc.y) * 10)
+
+ await backend.liha.set_z_travel_height([backend._z_range] * backend._num_channels)
+ await backend.liha.position_absolute_all_axis(
+ x, y, ys, [backend._z_range] * backend._num_channels
+ )
+
+ # Drop tips
+ try:
+ await backend.send_command("C5", command="SDT1,1000,200")
+ await backend.liha._drop_disposable_tip(255, discard_height=1)
+ print(" Tips dropped!")
+ except TecanError as e:
+ print(f" Drop failed: {e}")
+ print(" Trying ADT (simple discard)...")
+ try:
+ await backend.send_command("C5", command="ADT255")
+ print(" Tips dropped with ADT!")
+ except TecanError as e2:
+ print(f" ADT also failed: {e2}")
+
+ # Verify
+ resp = await backend.send_command("C5", command="RTS")
+ tip_status = resp["data"][0] if resp and resp.get("data") else 0
+ print(f" Final tip status: {tip_status}")
+
+ await backend.io.stop()
+ print("Done.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/try_exit_boot.py b/keyser-testing/try_exit_boot.py
new file mode 100644
index 00000000000..6a7e70f81b8
--- /dev/null
+++ b/keyser-testing/try_exit_boot.py
@@ -0,0 +1,128 @@
+"""Try exiting ZaapMotion boot mode with the 'X' command.
+
+The RoMa manual documents an 'X' boot command that exits bootloader
+and jumps to application firmware (if valid in flash). This script
+tests whether the same command works on ZaapMotion controllers.
+
+Run on a freshly rebooted EVO (ZaapMotion in boot mode).
+
+Usage:
+ python keyser-testing/try_exit_boot.py
+"""
+
+import asyncio
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import EVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+
+async def send(backend, module, raw_cmd, desc=""):
+ """Send a raw command and print result."""
+ try:
+ resp = await backend.send_command(module, command=raw_cmd)
+ data = resp.get("data", []) if resp else []
+ print(f" > {module},{raw_cmd:30s} -> OK {data} ({desc})")
+ return data
+ except TecanError as e:
+ print(f" > {module},{raw_cmd:30s} -> ERR {e.error_code}: {e.message} ({desc})")
+ return None
+ except Exception as e:
+ print(f" > {module},{raw_cmd:30s} -> {e} ({desc})")
+ return None
+
+
+async def main():
+ print("=" * 70)
+ print(" ZaapMotion Boot Mode Exit Test")
+ print("=" * 70)
+
+ backend = EVOBackend(diti_count=0, packet_read_timeout=30, read_timeout=120, write_timeout=120)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ try:
+ await backend.io.setup()
+ print(" USB connected!\n")
+ except Exception as e:
+ print(f" USB failed: {e}")
+ return
+
+ try:
+ # --- Step 1: Check current state ---
+ print("--- Step 1: Current ZaapMotion State ---")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}RFV0", f"Tip {tip+1} firmware")
+ print()
+
+ # --- Step 2: Try boot mode commands ---
+ print("--- Step 2: Boot Mode Probe ---")
+ await send(backend, "C5", "T20L", "Tip 1: read equipment type")
+ await send(backend, "C5", "T20N", "Tip 1: read number of nodes")
+ print()
+
+ # --- Step 3: Exit boot mode on all tips ---
+ print("--- Step 3: Send 'X' (Exit Boot Mode) to All Tips ---")
+ input(" Press Enter to send exit boot command...")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}X", f"Tip {tip+1}: exit boot mode")
+
+ # Give controllers time to boot into application mode
+ print("\n Waiting 3s for application firmware to start...")
+ await asyncio.sleep(3)
+
+ # --- Step 4: Check state after exit ---
+ print("\n--- Step 4: ZaapMotion State After Exit ---")
+ all_app_mode = True
+ for tip in range(8):
+ data = await send(backend, "C5", f"T2{tip}RFV0", f"Tip {tip+1} firmware")
+ if data and isinstance(data[0], str) and "BOOT" in data[0]:
+ all_app_mode = False
+
+ if all_app_mode:
+ print("\n All tips in application mode!")
+ else:
+ print("\n Some tips still in boot mode.")
+ print(" Trying alternate: longer wait + recheck...")
+ await asyncio.sleep(5)
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}RFV0", f"Tip {tip+1} firmware (retry)")
+
+ # --- Step 5: Try PIA ---
+ print("\n--- Step 5: Try PIA Init ---")
+ input(" Press Enter to try PIA (robot will move)...")
+
+ # Safety module setup (from EVOware sequence)
+ await send(backend, "O1", "SPN", "Power on")
+ await send(backend, "O1", "SPS3", "Set power state 3")
+ await send(backend, "C5", "T23SDO11,1", "ZaapMotion config")
+
+ await send(backend, "C5", "PIA", "*** Init all axes ***")
+
+ # --- Step 6: Check final state ---
+ print("\n--- Step 6: Final State ---")
+ err = await send(backend, "C5", "REE0", "Extended error codes")
+ cfg = await send(backend, "C5", "REE1", "Axis config")
+
+ if err and cfg and isinstance(err[0], str) and isinstance(cfg[0], str):
+ error_names = {0: "OK", 1: "Init failed", 7: "Not initialized"}
+ for i, (axis, code_char) in enumerate(zip(cfg[0], err[0])):
+ code = ord(code_char) - 0x40
+ desc = error_names.get(code, f"Unknown({code})")
+ label = f"{axis}{i-2}" if axis == "Z" else axis
+ status = "OK" if code == 0 else f"ERR {code}: {desc}"
+ print(f" {label} = {status}")
+
+ print("\n--- Done ---")
+
+ finally:
+ print("\n Disconnecting...")
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/try_zaapmotion_config.py b/keyser-testing/try_zaapmotion_config.py
new file mode 100644
index 00000000000..bfed9be2048
--- /dev/null
+++ b/keyser-testing/try_zaapmotion_config.py
@@ -0,0 +1,194 @@
+"""Try configuring ZaapMotion axes after exiting boot mode.
+
+After 'X' exits boot mode, the ZaapMotion controllers need motor
+configuration (SEI, PID) before PIA can init the Z-axes.
+
+This script tries various configuration commands from the DLL analysis:
+- SEI (motor initialization with speed/current params)
+- PID controller setup
+- Controller configuration
+
+Run on a freshly rebooted EVO.
+
+Usage:
+ python keyser-testing/try_zaapmotion_config.py
+"""
+
+import asyncio
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import EVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+
+async def send(backend, module, raw_cmd, desc=""):
+ """Send a raw command and print result."""
+ try:
+ resp = await backend.send_command(module, command=raw_cmd)
+ data = resp.get("data", []) if resp else []
+ print(f" > {module},{raw_cmd:35s} -> OK {data} ({desc})")
+ return data
+ except TecanError as e:
+ print(f" > {module},{raw_cmd:35s} -> ERR {e.error_code}: {e.message} ({desc})")
+ return None
+ except Exception as e:
+ print(f" > {module},{raw_cmd:35s} -> {e} ({desc})")
+ return None
+
+
+async def exit_boot_mode(backend):
+ """Send X to all tips to exit boot mode."""
+ print(" Exiting boot mode on all tips...")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}X", f"Tip {tip+1}: exit boot")
+ print(" Waiting 3s...")
+ await asyncio.sleep(3)
+
+ # Verify
+ all_app = True
+ for tip in range(8):
+ data = await send(backend, "C5", f"T2{tip}RFV0", f"Tip {tip+1}")
+ if data and isinstance(data[0], str) and "BOOT" in data[0]:
+ all_app = False
+ return all_app
+
+
+async def try_pia(backend):
+ """Try PIA and show results."""
+ print("\n Sending PIA...")
+ result = await send(backend, "C5", "PIA", "Init all axes")
+
+ err = await send(backend, "C5", "REE0", "Error codes")
+ cfg = await send(backend, "C5", "REE1", "Axis config")
+ if err and cfg and isinstance(err[0], str) and isinstance(cfg[0], str):
+ error_names = {0: "OK", 1: "Init failed", 7: "Not init'd"}
+ for i, (axis, code_char) in enumerate(zip(cfg[0], err[0])):
+ code = ord(code_char) - 0x40
+ desc = error_names.get(code, f"?({code})")
+ label = f"{axis}{i-2}" if axis == "Z" else axis
+ status = "OK" if code == 0 else f"ERR: {desc}"
+ print(f" {label} = {status}")
+
+ return err and isinstance(err[0], str) and "@" * 11 == err[0][:11]
+
+
+async def main():
+ print("=" * 70)
+ print(" ZaapMotion Configuration Test")
+ print("=" * 70)
+
+ backend = EVOBackend(diti_count=0, packet_read_timeout=30, read_timeout=120, write_timeout=120)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ try:
+ await backend.io.setup()
+ print(" USB connected!\n")
+ except Exception as e:
+ print(f" USB failed: {e}")
+ return
+
+ try:
+ # --- Exit boot mode ---
+ print("--- Step 1: Exit Boot Mode ---")
+ data = await send(backend, "C5", "T20RFV0", "Check current mode")
+ if data and isinstance(data[0], str) and "BOOT" in data[0]:
+ if not await exit_boot_mode(backend):
+ print(" Failed to exit boot mode!")
+ return
+ else:
+ print(" Already in application mode.")
+
+ # --- Query available commands ---
+ print("\n--- Step 2: Probe ZaapMotion App Commands ---")
+ # Try various commands to understand what's available
+ probe_cmds = [
+ # Report commands
+ ("T20RFV0", "Firmware version"),
+ ("T20RFV2", "Serial number"),
+ ("T20RPP0", "Plunger param 0"),
+ ("T20RPP7", "Plunger param 7 (syringe vol)"),
+ ("T20RPP10", "Plunger param 10"),
+ ("T20REE", "Extended error codes"),
+ ("T20RDS", "Dilutor error codes"),
+ # Try SEI with no params
+ ("T20SEI", "Motor init (SEI) - no params"),
+ # Try PIY (position init) on subdevice
+ ("T20PIY", "Position init Y (subdevice)"),
+ # Try PIA on subdevice
+ ("T20PIA", "Position init A (subdevice)"),
+ ]
+ for cmd, desc in probe_cmds:
+ await send(backend, "C5", cmd, desc)
+
+ # --- Try sending SEI with params to each tip ---
+ print("\n--- Step 3: Try SEI Motor Init ---")
+ print(" Trying SEI (motor init) with various param formats...")
+ input(" Press Enter...")
+
+ # Try SEI without params
+ await send(backend, "C5", "T20SEI", "SEI no params")
+ # Try SEI with speed param
+ await send(backend, "C5", "T20SEI270", "SEI speed=270")
+ # Try SEI with speed,current
+ await send(backend, "C5", "T20SEI270,500", "SEI speed=270,current=500")
+ # Try SEI with comma-separated
+ await send(backend, "C5", "T20SEI27,50", "SEI 27,50")
+
+ # --- Try initializing dilutor on each tip ---
+ print("\n--- Step 4: Try Dilutor Init (PID) ---")
+ # PID command initializes plunger and valve - sent through LiHa
+ await send(backend, "C5", "PID255", "Init all dilutors")
+
+ # Check dilutor status
+ await send(backend, "C5", "RDS", "Dilutor error status")
+
+ # --- Try PIA ---
+ print("\n--- Step 5: Try PIA ---")
+ input(" Press Enter (robot will move)...")
+ await send(backend, "O1", "SPN", "Power on")
+ await send(backend, "O1", "SPS3", "Power state 3")
+ await try_pia(backend)
+
+ # --- If PIA failed, try per-tip PIZ ---
+ print("\n--- Step 6: Try Per-Tip PIZ ---")
+ input(" Press Enter...")
+ ok = 0
+ for tip in range(1, 9):
+ mask = 1 << (tip - 1)
+ for speed in [270, 150, 80, 50]:
+ data = await send(backend, "C5", f"PIZ{mask},{speed}", f"Z{tip} speed={speed/10}mm/s")
+ if data is not None:
+ ok += 1
+ break
+ print(f"\n {ok}/8 Z-axes initialized")
+
+ # Try Y and X
+ await send(backend, "C5", "PIY", "Y-axis init")
+ await send(backend, "C5", "PIX", "X-axis init")
+
+ # Final state
+ print("\n--- Final State ---")
+ err = await send(backend, "C5", "REE0", "Error codes")
+ cfg = await send(backend, "C5", "REE1", "Axis config")
+ if err and cfg and isinstance(err[0], str) and isinstance(cfg[0], str):
+ error_names = {0: "OK", 1: "Init failed", 7: "Not init'd"}
+ for i, (axis, code_char) in enumerate(zip(cfg[0], err[0])):
+ code = ord(code_char) - 0x40
+ desc = error_names.get(code, f"?({code})")
+ label = f"{axis}{i-2}" if axis == "Z" else axis
+ print(f" {label} = {'OK' if code == 0 else f'ERR: {desc}'}")
+
+ print("\n--- Done ---")
+
+ finally:
+ print("\n Disconnecting...")
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/keyser-testing/zaapmotion_init.py b/keyser-testing/zaapmotion_init.py
new file mode 100644
index 00000000000..dc3c300cc66
--- /dev/null
+++ b/keyser-testing/zaapmotion_init.py
@@ -0,0 +1,227 @@
+"""ZaapMotion initialization — replays the exact EVOware USB config sequence.
+
+Exits boot mode, configures motor controllers (PID, encoder, current limits),
+then runs PIA to init all axes.
+
+Derived from USB capture of EVOware's zaapmotiondriver.dll scan phase.
+
+Usage:
+ python keyser-testing/zaapmotion_init.py
+"""
+
+import asyncio
+
+from pylabrobot.liquid_handling import LiquidHandler
+from pylabrobot.liquid_handling.backends.tecan import EVOBackend
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+# ZaapMotion motor configuration sequence (same for all 8 tips).
+# Captured from EVOware USB traffic — sent via transparent pipeline T2x.
+ZAAPMOTION_CONFIG = [
+ "CFE 255,500",
+ "CAD ADCA,0,12.5",
+ "CAD ADCB,1,12.5",
+ "EDF1",
+ "EDF4",
+ "CDO 11",
+ "EDF5",
+ "SIC 10,5",
+ "SEA ADD,H,4,STOP,1,0,0",
+ "CMTBLDC,1",
+ "CETQEP2,256,R",
+ "CECPOS,QEP2",
+ "CECCUR,QEP2",
+ "CEE OFF",
+ "STL80",
+ "SVL12,8,16",
+ "SVL24,20,28",
+ "SCL1,900,3.5",
+ "SCE HOLD,500",
+ "SCE MOVE,500",
+ "CIR0",
+ "PIDHOLD,D,1.2,1,-1,0.003,0,0,OFF",
+ "PIDMOVE,D,0.8,1,-1,0.004,0,0,OFF",
+ "PIDHOLD,Q,1.2,1,-1,0.003,0,0,OFF",
+ "PIDMOVE,Q,0.8,1,-1,0.004,0,0,OFF",
+ "PIDHOLD,POS,0.2,1,-1,0.02,4,0,OFF",
+ "PIDMOVE,POS,0.35,1,-1,0.1,3,0,OFF",
+ "PIDSPDELAY,0",
+ "SFF 0.045,0.4,0.041",
+ "SES 0",
+ "SPO0",
+ "SIA 0.01, 0.28, 0.0",
+ "WRP",
+]
+
+
+async def send(backend, module, raw_cmd, desc="", quiet=False):
+ """Send a raw command and print result."""
+ try:
+ resp = await backend.send_command(module, command=raw_cmd)
+ data = resp.get("data", []) if resp else []
+ if not quiet:
+ print(f" > {module},{raw_cmd:40s} -> OK {data} ({desc})")
+ return data
+ except TecanError as e:
+ if not quiet:
+ print(f" > {module},{raw_cmd:40s} -> ERR {e.error_code}: {e.message} ({desc})")
+ return None
+ except Exception as e:
+ if not quiet:
+ print(f" > {module},{raw_cmd:40s} -> {e} ({desc})")
+ return None
+
+
+async def configure_zaapmotion_tip(backend, tip: int, quiet: bool = False):
+ """Configure a single ZaapMotion tip (0-7).
+
+ Sequence: exit boot → verify app mode → send motor config → write params.
+ """
+ prefix = f"T2{tip}"
+ label = f"Tip {tip + 1}"
+
+ # Check current mode
+ data = await send(backend, "C5", f"{prefix}RFV", f"{label}: check mode", quiet=True)
+
+ # Exit boot if needed
+ if data and isinstance(data[0], str) and "BOOT" in data[0]:
+ await send(backend, "C5", f"{prefix}X", f"{label}: exit boot", quiet=quiet)
+ await asyncio.sleep(1)
+ # Verify transition
+ data = await send(backend, "C5", f"{prefix}RFV", f"{label}: verify app", quiet=True)
+ if data and isinstance(data[0], str) and "BOOT" in data[0]:
+ # Retry once with longer wait
+ await asyncio.sleep(1)
+ data = await send(backend, "C5", f"{prefix}RFV", f"{label}: verify app (retry)", quiet=True)
+ if data and isinstance(data[0], str) and "BOOT" in data[0]:
+ print(f" {label}: FAILED to exit boot mode!")
+ return False
+
+ if not quiet:
+ mode = data[0] if data and data[0] else "unknown"
+ print(f" {label}: {mode}")
+
+ # Send motor configuration
+ errors = 0
+ for cmd in ZAAPMOTION_CONFIG:
+ result = await send(backend, "C5", f"{prefix}{cmd}", f"{label}", quiet=True)
+ if result is None:
+ errors += 1
+ if not quiet:
+ print(f" FAILED: {prefix}{cmd}")
+
+ # EDF1 takes ~1s (EVOware shows 1.08s gap after it)
+ # WRP also takes time — wait handled by response
+
+ if not quiet:
+ if errors == 0:
+ print(f" {label}: configured ({len(ZAAPMOTION_CONFIG)} commands OK)")
+ else:
+ print(f" {label}: {errors}/{len(ZAAPMOTION_CONFIG)} commands failed")
+
+ return errors == 0
+
+
+async def main():
+ print("=" * 70)
+ print(" ZaapMotion Full Init (from USB capture)")
+ print("=" * 70)
+
+ backend = EVOBackend(diti_count=0, packet_read_timeout=30, read_timeout=120, write_timeout=120)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+
+ try:
+ await backend.io.setup()
+ print(" USB connected!\n")
+ except Exception as e:
+ print(f" USB failed: {e}")
+ return
+
+ try:
+ # --- Step 1: Configure all 8 ZaapMotion tips ---
+ print("--- Step 1: ZaapMotion Motor Configuration ---")
+ print(" Configuring 8 tips (exit boot + motor/PID/encoder config)...")
+ input(" Press Enter to start...")
+
+ all_ok = True
+ for tip in range(8):
+ ok = await configure_zaapmotion_tip(backend, tip)
+ if not ok:
+ all_ok = False
+
+ if not all_ok:
+ print("\n WARNING: Some tips failed configuration!")
+
+ # --- Step 2: Verify all tips in app mode ---
+ print("\n--- Step 2: Verify ZaapMotion State ---")
+ for tip in range(8):
+ await send(backend, "C5", f"T2{tip}RFV0", f"Tip {tip+1}")
+
+ # --- Step 3: Safety module + PIA ---
+ print("\n--- Step 3: Safety + PIA Init ---")
+ input(" Press Enter (robot WILL move)...")
+
+ await send(backend, "O1", "SPN", "Power on")
+ await send(backend, "O1", "SPS3", "Power state 3")
+ await send(backend, "C5", "T23SDO11,1", "ZaapMotion SDO config")
+
+ print("\n Sending PIA (takes ~24 seconds)...")
+ await send(backend, "C5", "PIA", "*** INIT ALL AXES ***")
+
+ # --- Step 4: Check result ---
+ print("\n--- Step 4: Init Result ---")
+ err = await send(backend, "C5", "REE0", "Error codes")
+ cfg = await send(backend, "C5", "REE1", "Axis config")
+ if err and cfg and isinstance(err[0], str) and isinstance(cfg[0], str):
+ all_init = True
+ error_names = {0: "OK", 1: "Init failed", 7: "Not initialized"}
+ for i, (axis, code_char) in enumerate(zip(cfg[0], err[0])):
+ code = ord(code_char) - 0x40
+ desc = error_names.get(code, f"Unknown({code})")
+ label = f"{axis}{i-2}" if axis == "Z" else axis
+ status = "OK" if code == 0 else f"ERR {code}: {desc}"
+ print(f" {label} = {status}")
+ if code != 0:
+ all_init = False
+
+ if all_init:
+ print("\n *** ALL AXES INITIALIZED SUCCESSFULLY! ***")
+
+ # Post-init sequence (from EVOware)
+ print("\n--- Step 5: Post-Init ---")
+ await send(backend, "C5", "BMX2", "Stop X")
+
+ # Init RoMa
+ choice = input(" Initialize RoMa? (y/n): ").strip().lower()
+ if choice == "y":
+ await send(backend, "C1", "PIA", "RoMa init")
+ await send(backend, "C1", "PIX", "RoMa X-axis")
+
+ # Dilutor init
+ await send(backend, "C5",
+ "SHZ2100,2100,2100,2100,2100,2100,2100,2100",
+ "Set Z travel height")
+ await send(backend, "C5",
+ "PAA132,2744,90,2100,2100,2100,2100,2100,2100,2100,2100",
+ "Move to wash station")
+ await send(backend, "C5", "PID255", "Init all dilutors")
+ await send(backend, "C5", "RDS", "Dilutor status")
+
+ print("\n *** FULLY INITIALIZED! ***")
+ else:
+ print("\n Init incomplete — some axes failed.")
+
+ print("\n--- Done ---")
+
+ finally:
+ print("\n Disconnecting...")
+ try:
+ await backend.io.stop()
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py b/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py
index c4d9d2f2a9c..c70288eb878 100644
--- a/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py
+++ b/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py
@@ -25,6 +25,7 @@
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
+ Mix,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
@@ -183,6 +184,10 @@ class EVOBackend(TecanLiquidHandler):
MCA = "W1"
PNP = "W2"
+ # Syringe LiHa conversion factors (overridden by AirEVOBackend)
+ STEPS_PER_UL = 3.0
+ SPEED_FACTOR = 6.0
+
def __init__(
self,
diti_count: int = 0,
@@ -304,18 +309,18 @@ async def setup(self):
await self.liha.position_absolute_all_axis(45, 1031, 90, [self._z_range] * self.num_channels)
async def setup_arm(self, module):
+ arm = EVOArm(self, module)
try:
if module == EVO.MCA:
- await self.send_command(module, command="PIB")
-
- await self.send_command(module, command="PIA")
+ await arm.position_init_bus()
+ await arm.position_init_all()
except TecanError as e:
if e.error_code == 5:
return False
raise e
if module != EVO.MCA:
- await self.send_command(module, command="BMX", params=[2])
+ await arm.set_bus_mode(2)
return True
@@ -323,8 +328,11 @@ async def _park_liha(self):
await self.liha.set_z_travel_height([self._z_range] * self.num_channels)
await self.liha.position_absolute_all_axis(45, 1031, 90, [self._z_range] * self.num_channels)
+ _roma_park_position = (9000, 2000, 2464, 1800)
+
async def _park_roma(self):
- await self.roma.set_vector_coordinate_position(1, 9000, 2000, 2464, 1800, None, 1, 0)
+ px, py, pz, pr = self._roma_park_position
+ await self.roma.set_vector_coordinate_position(1, px, py, pz, pr, None, 1, 0)
await self.roma.action_move_vector_coordinate_position()
async def _park_mca(self):
@@ -347,6 +355,64 @@ async def _park_mca(self):
await self.send_command(EVO.MCA, command="BMA", params=[0, 0, 0])
await asyncio.sleep(0.5)
+ # ============== Mixing, blow-out, tip presence ==============
+
+ async def _perform_mix(self, mix: Mix, use_channels: List[int]) -> None:
+ """Perform mix cycles at the current tip position.
+
+ Args:
+ mix: Mix parameters (volume, repetitions, flow_rate).
+ use_channels: Which channels to mix on.
+ """
+ pvl: List[Optional[int]] = [None] * self.num_channels
+ sep: List[Optional[int]] = [None] * self.num_channels
+ ppr_asp: List[Optional[int]] = [None] * self.num_channels
+ ppr_disp: List[Optional[int]] = [None] * self.num_channels
+
+ for channel in use_channels:
+ pvl[channel] = 0 # outlet
+ sep[channel] = int(mix.flow_rate * self.SPEED_FACTOR)
+ steps = int(mix.volume * self.STEPS_PER_UL)
+ ppr_asp[channel] = steps
+ ppr_disp[channel] = -steps
+
+ await self.liha.position_valve_logical(pvl)
+ await self.liha.set_end_speed_plunger(sep)
+
+ for _ in range(mix.repetitions):
+ await self.liha.move_plunger_relative(ppr_asp)
+ await self.liha.move_plunger_relative(ppr_disp)
+
+ async def _perform_blow_out(
+ self, ops: List[SingleChannelDispense], use_channels: List[int]
+ ) -> None:
+ """Push extra air volume after dispense to expel remaining liquid."""
+ pvl: List[Optional[int]] = [None] * self.num_channels
+ sep: List[Optional[int]] = [None] * self.num_channels
+ ppr: List[Optional[int]] = [None] * self.num_channels
+ has_blowout = False
+
+ for i, channel in enumerate(use_channels):
+ bov = ops[i].blow_out_air_volume
+ if bov is not None and bov > 0:
+ has_blowout = True
+ pvl[channel] = 0
+ sep[channel] = int(100 * self.SPEED_FACTOR)
+ ppr[channel] = -int(bov * self.STEPS_PER_UL)
+
+ if has_blowout:
+ await self.liha.position_valve_logical(pvl)
+ await self.liha.set_end_speed_plunger(sep)
+ await self.liha.move_plunger_relative(ppr)
+
+ async def request_tip_presence(self) -> List[Optional[bool]]:
+ """Query tip mounted status for each channel via RTS firmware command."""
+ statuses = await self.liha.read_tip_status()
+ result: List[Optional[bool]] = [None] * self.num_channels
+ for i in range(min(len(statuses), self.num_channels)):
+ result[i] = statuses[i]
+ return result
+
# ============== LiquidHandlerBackend methods ==============
async def aspirate(
@@ -411,6 +477,7 @@ async def aspirate(
assert tlc is not None
detproc = tlc.lld_mode # must be same for all channels?
sense = tlc.lld_conductivity
+ # Allow override via backend_kwargs (future: pass through from LiquidHandler)
await self.liha.set_detection_mode(detproc, sense)
ssl, sdl, sbl = self._liquid_detection(use_channels, tecan_liquid_classes)
await self.liha.set_search_speed(ssl)
@@ -440,6 +507,13 @@ async def aspirate(
await self.liha.set_end_speed_plunger(sep)
await self.liha.move_plunger_relative(ppr)
+ # Post-aspirate mix
+ mix_channels = [ch for ch, op in zip(use_channels, ops) if op.mix is not None]
+ if mix_channels:
+ mix_op = next(op for op in ops if op.mix is not None)
+ assert mix_op.mix is not None
+ await self._perform_mix(mix_op.mix, mix_channels)
+
async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]):
"""Dispense liquid from the specified channels.
@@ -479,6 +553,16 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
await self.liha.set_tracking_distance_z(stz)
await self.liha.move_tracking_relative(mtr)
+ # Blow-out
+ await self._perform_blow_out(ops, use_channels)
+
+ # Post-dispense mix
+ mix_channels = [ch for ch, op in zip(use_channels, ops) if op.mix is not None]
+ if mix_channels:
+ mix_op = next(op for op in ops if op.mix is not None)
+ assert mix_op.mix is not None
+ await self._perform_mix(mix_op.mix, mix_channels)
+
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]):
"""Pick up tips from a resource.
@@ -603,6 +687,16 @@ async def pick_up_resource(self, pickup: ResourcePickup):
await self.roma.set_gripper_params(100, 75)
await self.roma.grip_plate(h - 100)
+ # Verify plate was gripped
+ try:
+ g_pos = await self.roma.report_g_param(0)
+ if g_pos >= 900:
+ import logging
+
+ logging.getLogger(__name__).warning("Plate may not be gripped (G-axis position: %d)", g_pos)
+ except (TypeError, KeyError):
+ pass # RPG not available or response format unexpected
+
async def move_picked_up_resource(self, move: ResourceMove):
raise NotImplementedError()
@@ -929,6 +1023,39 @@ async def report_y_param(self, param: int) -> List[int]:
)["data"]
return resp
+ async def read_error_register(self, param: int = 0) -> str:
+ """Read error register (REE).
+
+ Args:
+ param: 0 = current errors, 1 = extended error info
+
+ Returns:
+ Error string where each character represents one axis status.
+ ``'@'`` = no error, ``'A'`` = init failed, ``'G'`` = not initialized.
+ """
+ resp = await self.backend.send_command(module=self.module, command="REE", params=[param])
+ return str(resp["data"][0]) if resp and resp.get("data") else ""
+
+ async def position_init_all(self) -> None:
+ """Initialize all axes (PIA)."""
+ await self.backend.send_command(module=self.module, command="PIA")
+
+ async def position_init_bus(self) -> None:
+ """Initialize bus (PIB). Used for MCA modules."""
+ await self.backend.send_command(module=self.module, command="PIB")
+
+ async def set_bus_mode(self, mode: int) -> None:
+ """Set bus mode (BMX).
+
+ Args:
+ mode: 2 = normal operation
+ """
+ await self.backend.send_command(module=self.module, command="BMX", params=[mode])
+
+ async def bus_module_action(self, p1: int, p2: int, p3: int) -> None:
+ """Bus module action (BMA). Use ``(0, 0, 0)`` to halt all axes."""
+ await self.backend.send_command(module=self.module, command="BMA", params=[p1, p2, p3])
+
class LiHa(EVOArm):
"""
@@ -1180,6 +1307,75 @@ async def get_disposable_tip(self, tips, z_start, z_search):
params=[tips, z_start, z_search, 0],
)
+ async def position_plunger_absolute(self, positions: List[Optional[int]]) -> None:
+ """Move plunger to absolute position (PPA).
+
+ Args:
+ positions: absolute plunger position in full steps per channel (0-3150).
+ 0 = fully dispensed, 3150 = fully aspirated.
+ """
+ await self.backend.send_command(module=self.module, command="PPA", params=positions)
+
+ async def set_disposable_tip_params(self, mode: int, z_discard: int, z_retract: int) -> None:
+ """Set disposable tip discard parameters (SDT).
+
+ Args:
+ mode: 1 = discard in rack
+ z_discard: Z discard distance in 1/10 mm
+ z_retract: Z retract distance in 1/10 mm
+ """
+ await self.backend.send_command(
+ module=self.module, command="SDT", params=[mode, z_discard, z_retract]
+ )
+
+ # ============== Query commands ==============
+
+ async def read_plunger_positions(self) -> List[int]:
+ """Read current plunger positions (RPP).
+
+ Returns:
+ List of plunger positions in full steps per channel.
+ """
+ resp: List[int] = (
+ await self.backend.send_command(module=self.module, command="RPP", params=[0])
+ )["data"]
+ return resp
+
+ async def read_z_after_liquid_detection(self) -> List[int]:
+ """Read Z values after liquid detection (RVZ).
+
+ Returns:
+ List of Z positions in 1/10 mm where liquid was detected, per channel.
+ """
+ resp: List[int] = (
+ await self.backend.send_command(module=self.module, command="RVZ", params=[0])
+ )["data"]
+ return resp
+
+ async def read_tip_status(self) -> List[bool]:
+ """Read tip mounted status for each channel (RTS).
+
+ Returns:
+ List of booleans: True if tip is mounted on that channel.
+
+ Note:
+ Response format needs hardware validation.
+ """
+ resp: List[int] = (
+ await self.backend.send_command(module=self.module, command="RTS", params=[0])
+ )["data"]
+ return [bool(v) for v in resp]
+
+ async def position_absolute_z_bulk(self, z: List[Optional[int]]) -> None:
+ """Position absolute Z for all channels simultaneously (PAZ).
+
+ Unlike :meth:`move_absolute_z` which uses slow speed, PAZ uses fast speed.
+
+ Args:
+ z: absolute Z position in 1/10 mm per channel
+ """
+ await self.backend.send_command(module=self.module, command="PAZ", params=z)
+
async def discard_disposable_tip_high(self, tips):
"""Drops tips
Discards at the Z-axes initialization height
diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py b/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py
index 723aebcff44..ef050c89e03 100644
--- a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py
+++ b/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py
@@ -429,6 +429,7 @@ async def test_move_resource(self):
call(module="C1", command="SFR", params=[2000, 600]),
call(module="C1", command="SGG", params=[100, 75, None]),
call(module="C1", command="AGR", params=[754]),
+ call(module="C1", command="RPG", params=[0]),
call(module="C1", command="RPZ", params=[5]),
call(module="C1", command="STW", params=[1, 0, 0, 0, 135, 0]),
call(module="C1", command="STW", params=[2, 0, 0, 0, 53, 0]),
diff --git a/pylabrobot/liquid_handling/backends/tecan/__init__.py b/pylabrobot/liquid_handling/backends/tecan/__init__.py
index de91df1008f..592597cfa0b 100644
--- a/pylabrobot/liquid_handling/backends/tecan/__init__.py
+++ b/pylabrobot/liquid_handling/backends/tecan/__init__.py
@@ -1 +1,2 @@
+from .air_evo_backend import AirEVOBackend
from .EVO_backend import EVOBackend
diff --git a/pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py b/pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py
new file mode 100644
index 00000000000..d82f7385750
--- /dev/null
+++ b/pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py
@@ -0,0 +1,666 @@
+"""Backend for Tecan Freedom EVO with Air LiHa (ZaapMotion controllers).
+
+The Air LiHa uses ZaapMotion BLDC motor controllers for air displacement
+pipetting, as opposed to the syringe-based XP2000/XP6000 dilutors. This
+requires:
+
+1. Boot mode exit and motor configuration on startup
+2. Different plunger conversion factors (106.4 steps/uL vs 3 for syringe)
+3. ZaapMotion force mode commands around each plunger operation
+
+See keyser-testing/AirLiHa_Investigation.md for full reverse-engineering details.
+"""
+
+import asyncio
+import logging
+from typing import List, Optional, Sequence, Tuple, Union
+
+from pylabrobot.liquid_handling.backends.tecan.errors import TecanError
+from pylabrobot.liquid_handling.backends.tecan.EVO_backend import EVOArm, EVOBackend, LiHa
+from pylabrobot.liquid_handling.liquid_classes.tecan import (
+ TecanLiquidClass,
+ get_liquid_class,
+)
+from pylabrobot.liquid_handling.standard import (
+ Drop,
+ Mix,
+ Pickup,
+ SingleChannelAspiration,
+ SingleChannelDispense,
+)
+from pylabrobot.resources import Liquid, TecanTip, TipSpot, Trash
+
+logger = logging.getLogger(__name__)
+
+# ZaapMotion motor configuration sequence, sent via transparent pipeline T2x.
+# Captured from EVOware USB traffic (zaapmotiondriver.dll scan phase).
+# Same config for all 8 tips.
+ZAAPMOTION_CONFIG = [
+ "CFE 255,500",
+ "CAD ADCA,0,12.5",
+ "CAD ADCB,1,12.5",
+ "EDF1",
+ "EDF4",
+ "CDO 11",
+ "EDF5",
+ "SIC 10,5",
+ "SEA ADD,H,4,STOP,1,0,0",
+ "CMTBLDC,1",
+ "CETQEP2,256,R",
+ "CECPOS,QEP2",
+ "CECCUR,QEP2",
+ "CEE OFF",
+ "STL80",
+ "SVL12,8,16",
+ "SVL24,20,28",
+ "SCL1,900,3.5",
+ "SCE HOLD,500",
+ "SCE MOVE,500",
+ "CIR0",
+ "PIDHOLD,D,1.2,1,-1,0.003,0,0,OFF",
+ "PIDMOVE,D,0.8,1,-1,0.004,0,0,OFF",
+ "PIDHOLD,Q,1.2,1,-1,0.003,0,0,OFF",
+ "PIDMOVE,Q,0.8,1,-1,0.004,0,0,OFF",
+ "PIDHOLD,POS,0.2,1,-1,0.02,4,0,OFF",
+ "PIDMOVE,POS,0.35,1,-1,0.1,3,0,OFF",
+ "PIDSPDELAY,0",
+ "SFF 0.045,0.4,0.041",
+ "SES 0",
+ "SPO0",
+ "SIA 0.01, 0.28, 0.0",
+ "WRP",
+]
+
+
+class ZaapMotion:
+ """Commands for ZaapMotion motor controllers (T2x pipeline)."""
+
+ def __init__(self, backend: EVOBackend, module: str = "C5"):
+ self.backend = backend
+ self.module = module
+
+ def _prefix(self, tip: int) -> str:
+ return f"T2{tip}"
+
+ async def exit_boot_mode(self, tip: int) -> None:
+ await self.backend.send_command(self.module, command=f"{self._prefix(tip)}X")
+
+ async def read_firmware_version(self, tip: int) -> str:
+ resp = await self.backend.send_command(self.module, command=f"{self._prefix(tip)}RFV")
+ return str(resp["data"][0]) if resp and resp.get("data") else ""
+
+ async def read_config_status(self, tip: int) -> None:
+ await self.backend.send_command(self.module, command=f"{self._prefix(tip)}RCS")
+
+ async def set_force_ramp(self, tip: int, value: int) -> None:
+ await self.backend.send_command(self.module, command=f"{self._prefix(tip)}SFR{value}")
+
+ async def set_force_mode(self, tip: int) -> None:
+ await self.backend.send_command(self.module, command=f"{self._prefix(tip)}SFP1")
+
+ async def set_default_position(self, tip: int, value: int) -> None:
+ await self.backend.send_command(self.module, command=f"{self._prefix(tip)}SDP{value}")
+
+ async def configure_motor(self, tip: int, command: str) -> None:
+ await self.backend.send_command(self.module, command=f"{self._prefix(tip)}{command}")
+
+ async def set_sdo(self, param: str) -> None:
+ await self.backend.send_command(self.module, command=f"T23SDO{param}")
+
+
+class AirEVOBackend(EVOBackend):
+ """Backend for Tecan Freedom EVO with Air LiHa (ZaapMotion controllers).
+
+ Usage::
+
+ from pylabrobot.liquid_handling import LiquidHandler
+ from pylabrobot.liquid_handling.backends.tecan import AirEVOBackend
+ from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
+
+ backend = AirEVOBackend(diti_count=8)
+ deck = EVO150Deck()
+ lh = LiquidHandler(backend=backend, deck=deck)
+ await lh.setup()
+ """
+
+ # Air LiHa plunger conversion factors (derived from USB capture analysis).
+ # Syringe LiHa uses 3 steps/uL and 6 half-steps/sec per uL/s.
+ STEPS_PER_UL = 106.4
+ SPEED_FACTOR = 213.0
+
+ # ZaapMotion force ramp values
+ SFR_ACTIVE = 133120 # high force ramp during plunger movement
+ SFR_IDLE = 3752 # low force ramp at rest
+ SDP_DEFAULT = 1400 # default dispense parameter
+
+ def __init__(
+ self,
+ diti_count: int = 0,
+ packet_read_timeout: int = 30,
+ read_timeout: int = 120,
+ write_timeout: int = 120,
+ ):
+ """Create a new Air EVO interface.
+
+ Args:
+ diti_count: number of channels configured for disposable tips.
+ 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.
+ """
+
+ super().__init__(
+ diti_count=diti_count,
+ packet_read_timeout=packet_read_timeout,
+ read_timeout=read_timeout,
+ write_timeout=write_timeout,
+ )
+ self.zaap: Optional[ZaapMotion] = None
+
+ async def setup(self):
+ """Setup the Air EVO.
+
+ Checks if axes are already initialized (e.g. from a previous session).
+ If so, skips ZaapMotion configuration and PIA. Otherwise performs full
+ boot exit, motor configuration, and initialization.
+ """
+
+ # Connect USB with short packet timeout for fast buffer drain
+ logger.info("Connecting USB...")
+ saved_prt = self.io.packet_read_timeout
+ self.io.packet_read_timeout = 1
+ await self.io.setup()
+ self.io.packet_read_timeout = saved_prt
+
+ # Check if already initialized
+ if await self._is_initialized():
+ logger.info("Axes already initialized — skipping ZaapMotion config + PIA.")
+ await self._setup_quick()
+ else:
+ logger.info("Axes not initialized — running full setup.")
+ await self._setup_full()
+
+ async def _is_initialized(self) -> bool:
+ """Check if the LiHa axes are already initialized."""
+ try:
+ arm = EVOArm(self, "C5")
+ err = await arm.read_error_register(0)
+ err = str(err)
+ # A = init failed (1), G = not initialized (7) — these mean we need full init
+ # Any other code (including @=OK, Y=tip not fetched, etc.) means axes are initialized
+ if err and not any(c in ("A", "G") for c in err):
+ return True
+ except (TecanError, TimeoutError):
+ pass
+ return False
+
+ async def _setup_quick(self):
+ """Fast setup when axes are already initialized. Skips ZaapMotion config and PIA."""
+ self._liha_connected = True
+ self._mca_connected = False
+ self._roma_connected = False
+ self.liha = LiHa(self, EVOBackend.LIHA)
+ self.zaap = ZaapMotion(self)
+ self._num_channels = await self.liha.report_number_tips()
+ self._x_range = await self.liha.report_x_param(5)
+ self._y_range = (await self.liha.report_y_param(5))[0]
+ self._z_range = (await self.liha.report_z_param(5))[0]
+ logger.info("Quick setup complete: %d channels, z_range=%d", self._num_channels, self._z_range)
+
+ async def _setup_full(self):
+ """Full setup: ZaapMotion config, safety module, PIA, dilutor init."""
+
+ # Configure ZaapMotion controllers before PIA
+ logger.info("Configuring ZaapMotion controllers...")
+ await self._configure_zaapmotion()
+
+ # Safety module: enable motor power
+ logger.info("Enabling safety module / motor power...")
+ await self._setup_safety_module()
+
+ # ZaapMotion SDO config (from EVOware: sent right before PIA)
+ assert self.zaap is not None
+ try:
+ await self.zaap.set_sdo("11,1")
+ except TecanError:
+ pass # may fail if already in app mode, non-critical
+
+ # Standard arm init (PIA, BMX, etc.)
+ logger.info("Initializing arms (PIA)...")
+ self._liha_connected = await self.setup_arm(EVOBackend.LIHA)
+ self._mca_connected = await self.setup_arm(EVOBackend.MCA)
+ self._roma_connected = await self.setup_arm(EVOBackend.ROMA)
+
+ if self.roma_connected:
+ from pylabrobot.liquid_handling.backends.tecan.EVO_backend import RoMa
+
+ self.roma = RoMa(self, EVOBackend.ROMA)
+ await self.roma.position_initialization_x()
+ await self._park_roma()
+
+ if self.liha_connected:
+ self.liha = LiHa(self, EVOBackend.LIHA)
+ await self.liha.position_initialization_x()
+
+ self._num_channels = await self.liha.report_number_tips()
+ self._x_range = await self.liha.report_x_param(5)
+ self._y_range = (await self.liha.report_y_param(5))[0]
+ self._z_range = (await self.liha.report_z_param(5))[0]
+
+ # Initialize dilutors (Air LiHa uses same PID/PVL/PPR sequence as syringe)
+ await self.liha.set_z_travel_height([self._z_range] * self.num_channels)
+ await self.liha.position_absolute_all_axis(45, 1031, 90, [1200] * self.num_channels)
+ await self.liha.initialize_plunger(self._bin_use_channels(list(range(self.num_channels))))
+ await self.liha.position_valve_logical([1] * self.num_channels)
+ await self.liha.move_plunger_relative([100] * self.num_channels)
+ await self.liha.position_valve_logical([0] * self.num_channels)
+ await self.liha.set_end_speed_plunger([1800] * self.num_channels)
+ await self.liha.move_plunger_relative([-100] * self.num_channels)
+ await self.liha.position_absolute_all_axis(45, 1031, 90, [self._z_range] * self.num_channels)
+
+ async def _configure_zaapmotion(self):
+ """Exit boot mode and configure all 8 ZaapMotion motor controllers.
+
+ Each controller boots into bootloader mode (XP2-BOOT) after power cycle.
+ This sends the 'X' command to jump to application firmware, then sends
+ 33 motor configuration commands (PID gains, current limits, encoder config).
+ """
+ zaap = ZaapMotion(self)
+ for tip in range(8):
+ # Check current mode
+ try:
+ firmware = await zaap.read_firmware_version(tip)
+ except TecanError:
+ firmware = ""
+
+ if "BOOT" in str(firmware):
+ logger.info("ZaapMotion tip %d in boot mode, sending exit command", tip + 1)
+ await zaap.exit_boot_mode(tip)
+ await asyncio.sleep(1)
+
+ # Verify transition
+ try:
+ firmware = await zaap.read_firmware_version(tip)
+ except TecanError:
+ firmware = ""
+
+ if "BOOT" in str(firmware):
+ raise TecanError(f"ZaapMotion tip {tip + 1} failed to exit boot mode", "C5", 1)
+
+ # Check if already configured (RCS returns OK if configured)
+ try:
+ await zaap.read_config_status(tip)
+ logger.info("ZaapMotion tip %d already configured, skipping", tip + 1)
+ continue
+ except TecanError:
+ pass # not configured, proceed with config
+
+ # Send motor configuration
+ logger.info("Configuring ZaapMotion tip %d (%d commands)", tip + 1, len(ZAAPMOTION_CONFIG))
+ for cmd in ZAAPMOTION_CONFIG:
+ try:
+ await zaap.configure_motor(tip, cmd)
+ except TecanError as e:
+ logger.warning("ZaapMotion tip %d config command '%s' failed: %s", tip + 1, cmd, e)
+
+ self.zaap = zaap
+
+ async def _setup_safety_module(self):
+ """Send safety module commands to enable motor power."""
+ try:
+ await self.send_command("O1", command="SPN")
+ await self.send_command("O1", command="SPS3")
+ except TecanError as e:
+ logger.warning("Safety module command failed: %s", e)
+
+ # ============== ZaapMotion force mode helpers ==============
+
+ async def _zaapmotion_force_on(self):
+ """Enable ZaapMotion force mode before plunger operations."""
+ assert self.zaap is not None
+ for tip in range(8):
+ await self.zaap.set_force_ramp(tip, self.SFR_ACTIVE)
+ for tip in range(8):
+ await self.zaap.set_force_mode(tip)
+
+ async def _zaapmotion_force_off(self):
+ """Restore ZaapMotion to idle after plunger operations."""
+ assert self.zaap is not None
+ for tip in range(8):
+ await self.zaap.set_force_ramp(tip, self.SFR_IDLE)
+ for tip in range(8):
+ await self.zaap.set_default_position(tip, self.SDP_DEFAULT)
+
+ # ============== Force-mode overrides for mixing and blow-out ==============
+
+ async def _perform_mix(self, mix: Mix, use_channels: List[int]) -> None:
+ await self._zaapmotion_force_on()
+ await super()._perform_mix(mix, use_channels)
+ await self._zaapmotion_force_off()
+
+ async def _perform_blow_out(
+ self, ops: List[SingleChannelDispense], use_channels: List[int]
+ ) -> None:
+ await self._zaapmotion_force_on()
+ await super()._perform_blow_out(ops, use_channels)
+ await self._zaapmotion_force_off()
+
+ # ============== Override conversion factors ==============
+
+ def _aspirate_airgap(
+ self,
+ use_channels: List[int],
+ tecan_liquid_classes: List[Optional[TecanLiquidClass]],
+ airgap: str,
+ ) -> Tuple[List[Optional[int]], List[Optional[int]], List[Optional[int]]]:
+ pvl: List[Optional[int]] = [None] * self.num_channels
+ sep: List[Optional[int]] = [None] * self.num_channels
+ ppr: List[Optional[int]] = [None] * self.num_channels
+
+ for i, channel in enumerate(use_channels):
+ tlc = tecan_liquid_classes[i]
+ assert tlc is not None
+ pvl[channel] = 0
+ if airgap == "lag":
+ sep[channel] = int(tlc.aspirate_lag_speed * self.SPEED_FACTOR)
+ ppr[channel] = int(tlc.aspirate_lag_volume * self.STEPS_PER_UL)
+ elif airgap == "tag":
+ sep[channel] = int(tlc.aspirate_tag_speed * self.SPEED_FACTOR)
+ ppr[channel] = int(tlc.aspirate_tag_volume * self.STEPS_PER_UL)
+
+ return pvl, sep, ppr
+
+ def _aspirate_action(
+ self,
+ ops: Sequence[Union[SingleChannelAspiration, SingleChannelDispense]],
+ use_channels: List[int],
+ tecan_liquid_classes: List[Optional[TecanLiquidClass]],
+ zadd: List[Optional[int]],
+ ) -> Tuple[
+ List[Optional[int]],
+ List[Optional[int]],
+ List[Optional[int]],
+ List[Optional[int]],
+ List[Optional[int]],
+ ]:
+ ssz: List[Optional[int]] = [None] * self.num_channels
+ sep: List[Optional[int]] = [None] * self.num_channels
+ stz: List[Optional[int]] = [-z if z else None for z in zadd]
+ mtr: List[Optional[int]] = [None] * self.num_channels
+ ssz_r: List[Optional[int]] = [None] * self.num_channels
+
+ for i, channel in enumerate(use_channels):
+ tlc = tecan_liquid_classes[i]
+ z = zadd[channel]
+ assert tlc is not None and z is not None
+ flow_rate = ops[i].flow_rate or tlc.aspirate_speed
+ sep[channel] = int(flow_rate * self.SPEED_FACTOR)
+ ssz[channel] = round(z * flow_rate / ops[i].volume)
+ volume = tlc.compute_corrected_volume(ops[i].volume)
+ mtr[channel] = round(volume * self.STEPS_PER_UL)
+ ssz_r[channel] = int(tlc.aspirate_retract_speed * 10)
+
+ return ssz, sep, stz, mtr, ssz_r
+
+ def _dispense_action(
+ self,
+ ops: Sequence[Union[SingleChannelAspiration, SingleChannelDispense]],
+ use_channels: List[int],
+ tecan_liquid_classes: List[Optional[TecanLiquidClass]],
+ ) -> Tuple[
+ List[Optional[int]],
+ List[Optional[int]],
+ List[Optional[int]],
+ List[Optional[int]],
+ ]:
+ sep: List[Optional[int]] = [None] * self.num_channels
+ spp: List[Optional[int]] = [None] * self.num_channels
+ stz: List[Optional[int]] = [None] * self.num_channels
+ mtr: List[Optional[int]] = [None] * self.num_channels
+
+ for i, channel in enumerate(use_channels):
+ tlc = tecan_liquid_classes[i]
+ assert tlc is not None
+ flow_rate = ops[i].flow_rate or tlc.dispense_speed
+ sep[channel] = int(flow_rate * self.SPEED_FACTOR)
+ spp[channel] = int(tlc.dispense_breakoff * self.SPEED_FACTOR)
+ stz[channel] = 0
+ volume = (
+ tlc.compute_corrected_volume(ops[i].volume)
+ + tlc.aspirate_lag_volume
+ + tlc.aspirate_tag_volume
+ )
+ mtr[channel] = -round(volume * self.STEPS_PER_UL)
+
+ return sep, spp, stz, mtr
+
+ # ============== Override liquid handling methods ==============
+
+ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int]):
+ x_positions, y_positions, z_positions = self._liha_positions(ops, use_channels)
+
+ tecan_liquid_classes = [
+ get_liquid_class(
+ target_volume=op.volume,
+ liquid_class=Liquid.WATER,
+ tip_type=op.tip.tip_type,
+ )
+ if isinstance(op.tip, TecanTip)
+ else None
+ for op in ops
+ ]
+
+ from pylabrobot.resources import TecanPlate
+
+ # Y-spacing: use the plate's well pitch (item_dy), not individual well size
+ plate = ops[0].resource.parent
+ if plate is not None and hasattr(plate, "item_dy"):
+ ys = int(plate.item_dy * 10) # type: ignore[union-attr]
+ else:
+ ys = int(ops[0].resource.get_absolute_size_y() * 10)
+ zadd: List[Optional[int]] = [0] * self.num_channels
+ for i, channel in enumerate(use_channels):
+ par = ops[i].resource.parent
+ if par is None:
+ continue
+ if not isinstance(par, TecanPlate):
+ raise ValueError(f"Operation is not supported by resource {par}.")
+ zadd[channel] = round(ops[i].volume / par.area * 10) # type: ignore[call-overload]
+
+ x, _ = self._first_valid(x_positions)
+ y, yi = self._first_valid(y_positions)
+ assert x is not None and y is not None
+
+ # The z_positions from _liha_positions use get_z_position which adds carrier
+ # offset and tip_length to absolute Z values — producing out-of-range results.
+ # Use the plate's z_start/z_dispense directly instead.
+ from pylabrobot.resources import TecanPlate as _TP
+
+ first_par = ops[0].resource.parent
+ if isinstance(first_par, _TP):
+ z_travel = [self._z_range] * self.num_channels
+ z_aspirate: List[Optional[int]] = [None] * self.num_channels
+ for i, channel in enumerate(use_channels):
+ z_aspirate[channel] = int(first_par.z_start)
+ else:
+ z_travel = [z if z else self._z_range for z in z_positions["travel"]]
+ z_aspirate = z_positions["start"]
+
+ paa_y = y - yi * ys
+ print(f" [DEBUG] PAA: x={x}, y={paa_y}, ys={ys}, z_travel={z_travel}")
+ print(f" [DEBUG] z_range={self._z_range}, z_aspirate={z_aspirate}")
+
+ await self.liha.set_z_travel_height(z_travel)
+ await self.liha.position_absolute_all_axis(
+ x,
+ paa_y,
+ ys,
+ z_travel,
+ )
+
+ # Aspirate leading airgap (with force mode)
+ pvl, sep, ppr = self._aspirate_airgap(use_channels, tecan_liquid_classes, "lag")
+ if any(ppr):
+ await self._zaapmotion_force_on()
+ await self.liha.position_valve_logical(pvl)
+ await self.liha.set_end_speed_plunger(sep)
+ await self.liha.move_plunger_relative(ppr)
+ await self._zaapmotion_force_off()
+
+ # Liquid level detection
+ if any(tlc.aspirate_lld if tlc is not None else None for tlc in tecan_liquid_classes):
+ tlc, _ = self._first_valid(tecan_liquid_classes)
+ assert tlc is not None
+ detproc = tlc.lld_mode
+ sense = tlc.lld_conductivity
+ await self.liha.set_detection_mode(detproc, sense)
+ ssl, sdl, sbl = self._liquid_detection(use_channels, tecan_liquid_classes)
+ await self.liha.set_search_speed(ssl)
+ await self.liha.set_search_retract_distance(sdl)
+ await self.liha.set_search_z_start(z_positions["start"])
+ await self.liha.set_search_z_max(list(z if z else self._z_range for z in z_positions["max"]))
+ await self.liha.set_search_submerge(sbl)
+ shz = [min(z for z in z_positions["travel"] if z)] * self.num_channels
+ await self.liha.set_z_travel_height(shz)
+ await self.liha.move_detect_liquid(self._bin_use_channels(use_channels), zadd)
+ await self.liha.set_z_travel_height([self._z_range] * self.num_channels)
+
+ # Aspirate + retract (with force mode)
+ zadd = [min(z, 32) if z else None for z in zadd]
+ ssz, sep, stz, mtr, ssz_r = self._aspirate_action(ops, use_channels, tecan_liquid_classes, zadd)
+ await self.liha.set_slow_speed_z(ssz)
+ await self._zaapmotion_force_on()
+ await self.liha.set_end_speed_plunger(sep)
+ await self.liha.set_tracking_distance_z(stz)
+ await self.liha.move_tracking_relative(mtr)
+ await self._zaapmotion_force_off()
+ await self.liha.set_slow_speed_z(ssz_r)
+ await self.liha.move_absolute_z(z_positions["start"])
+
+ # Aspirate trailing airgap (with force mode)
+ pvl, sep, ppr = self._aspirate_airgap(use_channels, tecan_liquid_classes, "tag")
+ await self._zaapmotion_force_on()
+ await self.liha.position_valve_logical(pvl)
+ await self.liha.set_end_speed_plunger(sep)
+ await self.liha.move_plunger_relative(ppr)
+ await self._zaapmotion_force_off()
+
+ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]):
+ x_positions, y_positions, z_positions = self._liha_positions(ops, use_channels)
+ plate = ops[0].resource.parent
+ if plate is not None and hasattr(plate, "item_dy"):
+ ys = int(plate.item_dy * 10) # type: ignore[union-attr]
+ else:
+ ys = int(ops[0].resource.get_absolute_size_y() * 10)
+
+ tecan_liquid_classes = [
+ get_liquid_class(
+ target_volume=op.volume,
+ liquid_class=Liquid.WATER,
+ tip_type=op.tip.tip_type,
+ )
+ if isinstance(op.tip, TecanTip)
+ else None
+ for op in ops
+ ]
+
+ x, _ = self._first_valid(x_positions)
+ y, yi = self._first_valid(y_positions)
+ assert x is not None and y is not None
+ await self.liha.set_z_travel_height([z if z else self._z_range for z in z_positions["travel"]])
+ await self.liha.position_absolute_all_axis(
+ x,
+ y - yi * ys,
+ ys,
+ [z if z else self._z_range for z in z_positions["dispense"]],
+ )
+
+ sep, spp, stz, mtr = self._dispense_action(ops, use_channels, tecan_liquid_classes)
+ await self._zaapmotion_force_on()
+ await self.liha.set_end_speed_plunger(sep)
+ await self.liha.set_stop_speed_plunger(spp)
+ await self.liha.set_tracking_distance_z(stz)
+ await self.liha.move_tracking_relative(mtr)
+ await self._zaapmotion_force_off()
+
+ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]):
+ assert min(use_channels) >= self.num_channels - self.diti_count, (
+ f"DiTis can only be configured for the last {self.diti_count} channels"
+ )
+
+ # Use _liha_positions for X/Y only; Z for tip pickup is computed directly
+ # from the tip rack's z_start (absolute Tecan Z coordinate).
+ x_positions, y_positions, _ = self._liha_positions(ops, use_channels)
+
+ ys = int(ops[0].resource.get_absolute_size_y() * 10)
+ x, _ = self._first_valid(x_positions)
+ y, yi = self._first_valid(y_positions)
+ assert x is not None and y is not None
+ # TODO: calibrate X offset properly in resource definitions
+ x += 60 # temporary 6mm X offset from taught position
+ await self.liha.set_z_travel_height([self._z_range] * self.num_channels)
+ await self.liha.position_absolute_all_axis(
+ x, y - yi * ys, ys, [self._z_range] * self.num_channels
+ )
+
+ # Aspirate small air gap before tip pickup (with Air LiHa conversion factors)
+ pvl: List[Optional[int]] = [None] * self.num_channels
+ sep: List[Optional[int]] = [None] * self.num_channels
+ ppr: List[Optional[int]] = [None] * self.num_channels
+ for channel in use_channels:
+ pvl[channel] = 0
+ sep[channel] = int(70 * self.SPEED_FACTOR)
+ ppr[channel] = int(10 * self.STEPS_PER_UL)
+ await self._zaapmotion_force_on()
+ await self.liha.position_valve_logical(pvl)
+ await self.liha.set_end_speed_plunger(sep)
+ await self.liha.move_plunger_relative(ppr)
+ await self._zaapmotion_force_off()
+
+ # AGT Z-start: use the tip rack's z_start directly.
+ # Tecan Z coordinates: 0=top (home), z_range=worktable.
+ # z_start is an absolute position — the force feedback handles the rest.
+ from pylabrobot.resources import TecanTipRack
+
+ par = ops[0].resource.parent
+ assert isinstance(par, TecanTipRack), f"Expected TecanTipRack, got {type(par)}"
+ agt_z_start = int(par.z_start)
+ agt_z_search = abs(int(par.z_max - par.z_start))
+ logger.info("AGT z_start=%d, z_search=%d", agt_z_start, agt_z_search)
+ await self.liha.get_disposable_tip(
+ self._bin_use_channels(use_channels), agt_z_start, agt_z_search
+ )
+
+ async def drop_tips(self, ops: List[Drop], use_channels: List[int]):
+ assert min(use_channels) >= self.num_channels - self.diti_count, (
+ f"DiTis can only be configured for the last {self.diti_count} channels"
+ )
+ assert all(isinstance(op.resource, (Trash, TipSpot)) for op in ops), (
+ "Must drop in waste container or tip rack"
+ )
+
+ x_positions, y_positions, _ = self._liha_positions(ops, use_channels)
+
+ ys = int(ops[0].resource.get_absolute_size_y() * 10)
+ x, _ = self._first_valid(x_positions)
+ y, yi = self._first_valid(y_positions)
+ assert x is not None and y is not None
+ await self.liha.set_z_travel_height([self._z_range] * self.num_channels)
+ await self.liha.position_absolute_all_axis(
+ x,
+ y - yi * ys,
+ ys,
+ [self._z_range] * self.num_channels,
+ )
+
+ # Empty plunger before discard (from EVOware capture: PPA0 = absolute position 0)
+ await self.liha.position_valve_logical([0] * self.num_channels)
+ await self._zaapmotion_force_on()
+ sep_vals: List[Optional[int]] = [int(600 * self.SPEED_FACTOR)] * self.num_channels
+ await self.liha.set_end_speed_plunger(sep_vals)
+ await self.liha.position_plunger_absolute([0] * self.num_channels)
+ await self._zaapmotion_force_off()
+
+ # Set DiTi discard parameters and drop
+ await self.liha.set_disposable_tip_params(1, 1000, 200)
+ await self.liha._drop_disposable_tip(self._bin_use_channels(use_channels), discard_height=1)
diff --git a/pylabrobot/liquid_handling/backends/tecan/air_evo_tests.py b/pylabrobot/liquid_handling/backends/tecan/air_evo_tests.py
new file mode 100644
index 00000000000..dbb3ebe061c
--- /dev/null
+++ b/pylabrobot/liquid_handling/backends/tecan/air_evo_tests.py
@@ -0,0 +1,291 @@
+"""Unit tests for AirEVOBackend.
+
+Tests conversion factors, ZaapMotion config sequence, force mode wrapping,
+and init-skip logic — all with mocked USB (no hardware needed).
+"""
+
+import unittest
+import unittest.mock
+from unittest.mock import AsyncMock, call
+
+from pylabrobot.liquid_handling.backends.tecan.air_evo_backend import (
+ ZAAPMOTION_CONFIG,
+ AirEVOBackend,
+ ZaapMotion,
+)
+from pylabrobot.liquid_handling.backends.tecan.EVO_backend import LiHa
+from pylabrobot.liquid_handling.standard import Pickup
+from pylabrobot.resources import (
+ Coordinate,
+ EVO150Deck,
+)
+from pylabrobot.resources.tecan.plate_carriers import MP_3Pos
+from pylabrobot.resources.tecan.plates import Microplate_96_Well
+from pylabrobot.resources.tecan.tip_carriers import DiTi_3Pos
+from pylabrobot.resources.tecan.tip_racks import DiTi_50ul_SBS_LiHa
+
+
+class AirEVOTestBase(unittest.IsolatedAsyncioTestCase):
+ """Base class with mocked AirEVOBackend setup."""
+
+ def setUp(self):
+ super().setUp()
+
+ self.evo = AirEVOBackend(diti_count=8)
+ self.mock_send = AsyncMock()
+ self.evo.send_command = self.mock_send # type: ignore[method-assign]
+
+ async def send_command(module, command, params=None, **kwargs):
+ if command == "RPX":
+ return {"data": [9000]}
+ if command == "RPY":
+ return {"data": [90]}
+ if command == "RPZ":
+ return {"data": [2100]}
+ if command == "RNT":
+ return {"data": [8]}
+ if command == "REE":
+ if params and params[0] == 1:
+ return {"data": ["XYSZZZZZZZZ"]}
+ return {"data": ["@@@@@@@@@@@"]}
+ if command.startswith("T2") and "RFV" in command:
+ return {"data": ["XP2000-V1.20-02/2015", "1.2.0.10946", "ZMA"]}
+ if command.startswith("T2") and "RCS" in command:
+ return {"data": []}
+ return {"data": []}
+
+ self.mock_send.side_effect = send_command
+
+ self.deck = EVO150Deck()
+ self.evo.set_deck(self.deck)
+
+ self.evo.setup = AsyncMock() # type: ignore[method-assign]
+ self.evo._num_channels = 8
+ self.evo._x_range = 9866
+ self.evo._y_range = 2833
+ self.evo._z_range = 2100
+ self.evo._liha_connected = True
+ self.evo._roma_connected = False
+ self.evo._mca_connected = False
+ self.evo.liha = LiHa(self.evo, "C5")
+ self.evo.zaap = ZaapMotion(self.evo)
+
+ # Deck setup
+ self.tip_carrier = DiTi_3Pos(name="tip_carrier")
+ self.tip_carrier[0] = self.tip_rack = DiTi_50ul_SBS_LiHa(name="tips")
+ self.deck.assign_child_resource(self.tip_carrier, rails=15)
+
+ self.plate_carrier = MP_3Pos(name="plate_carrier")
+ self.plate_carrier[0] = self.plate = Microplate_96_Well(name="plate")
+ self.deck.assign_child_resource(self.plate_carrier, rails=25)
+
+ self.mock_send.reset_mock()
+
+
+class ConversionFactorTests(AirEVOTestBase):
+ """Test that Air LiHa uses correct conversion factors (106.4/213)."""
+
+ def test_steps_per_ul(self):
+ self.assertEqual(AirEVOBackend.STEPS_PER_UL, 106.4)
+
+ def test_speed_factor(self):
+ self.assertEqual(AirEVOBackend.SPEED_FACTOR, 213.0)
+
+ def test_aspirate_airgap_uses_air_factors(self):
+ """Verify airgap calculation uses 106.4 steps/uL and 213 speed factor."""
+ from pylabrobot.liquid_handling.liquid_classes.tecan import TecanLiquidClass
+
+ tlc = 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.0,
+ calibration_offset=0,
+ aspirate_speed=50,
+ aspirate_delay=200,
+ 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=False,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=5,
+ 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=7,
+ 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,
+ )
+
+ # Leading airgap: 10uL at 70uL/s
+ pvl, sep, ppr = self.evo._aspirate_airgap([0], [tlc], "lag")
+ self.assertEqual(sep[0], int(70 * 213)) # 14910
+ self.assertEqual(ppr[0], int(10 * 106.4)) # 1064
+
+ # Trailing airgap: 5uL at 20uL/s
+ pvl, sep, ppr = self.evo._aspirate_airgap([0], [tlc], "tag")
+ self.assertEqual(sep[0], int(20 * 213)) # 4260
+ self.assertEqual(ppr[0], int(5 * 106.4)) # 532
+
+
+class ForceModeSFRTests(AirEVOTestBase):
+ """Test that force mode commands are sent correctly."""
+
+ def test_sfr_constants(self):
+ self.assertEqual(AirEVOBackend.SFR_ACTIVE, 133120)
+ self.assertEqual(AirEVOBackend.SFR_IDLE, 3752)
+ self.assertEqual(AirEVOBackend.SDP_DEFAULT, 1400)
+
+ async def test_force_on_sends_sfr_and_sfp(self):
+ await self.evo._zaapmotion_force_on()
+
+ # Should send SFR to all 8 tips, then SFP1 to all 8 tips
+ sfr_calls = [c for c in self.mock_send.call_args_list if "SFR133120" in str(c)]
+ sfp_calls = [c for c in self.mock_send.call_args_list if "SFP1" in str(c)]
+ self.assertEqual(len(sfr_calls), 8)
+ self.assertEqual(len(sfp_calls), 8)
+
+ async def test_force_off_sends_sfr_and_sdp(self):
+ await self.evo._zaapmotion_force_off()
+
+ sfr_calls = [c for c in self.mock_send.call_args_list if "SFR3752" in str(c)]
+ sdp_calls = [c for c in self.mock_send.call_args_list if "SDP1400" in str(c)]
+ self.assertEqual(len(sfr_calls), 8)
+ self.assertEqual(len(sdp_calls), 8)
+
+
+class InitSkipTests(AirEVOTestBase):
+ """Test init-skip logic."""
+
+ async def test_is_initialized_all_ok(self):
+ """REE0 returning all '@' should mean initialized."""
+ result = await self.evo._is_initialized()
+ self.assertTrue(result)
+
+ async def test_is_initialized_with_init_failed(self):
+ """REE0 with 'A' (init failed) should mean not initialized."""
+
+ async def send_cmd(module, command, params=None, **kwargs):
+ if command == "REE":
+ return {"data": ["GGGAAAAAAAA"]}
+ return {"data": []}
+
+ self.mock_send.side_effect = send_cmd
+ result = await self.evo._is_initialized()
+ self.assertFalse(result)
+
+ async def test_is_initialized_with_not_initialized(self):
+ """REE0 with 'G' (not initialized) should mean not initialized."""
+
+ async def send_cmd(module, command, params=None, **kwargs):
+ if command == "REE":
+ return {"data": ["GGGGGGGGGGG"]}
+ return {"data": []}
+
+ self.mock_send.side_effect = send_cmd
+ result = await self.evo._is_initialized()
+ self.assertFalse(result)
+
+ async def test_is_initialized_with_tip_not_fetched(self):
+ """REE0 with 'Y' (tip not fetched) means axes ARE initialized."""
+
+ async def send_cmd(module, command, params=None, **kwargs):
+ if command == "REE":
+ return {"data": ["@@@YYYYYYY@"]}
+ return {"data": []}
+
+ self.mock_send.side_effect = send_cmd
+ result = await self.evo._is_initialized()
+ self.assertTrue(result)
+
+
+class ZaapMotionConfigTests(AirEVOTestBase):
+ """Test ZaapMotion boot exit and motor configuration."""
+
+ def test_config_sequence_length(self):
+ """Verify all 33 config commands are defined."""
+ self.assertEqual(len(ZAAPMOTION_CONFIG), 33)
+
+ def test_config_starts_with_cfe(self):
+ self.assertEqual(ZAAPMOTION_CONFIG[0], "CFE 255,500")
+
+ def test_config_ends_with_wrp(self):
+ self.assertEqual(ZAAPMOTION_CONFIG[-1], "WRP")
+
+ async def test_configure_skips_when_rcs_ok(self):
+ """If RCS returns OK, skip motor config (already configured)."""
+ await self.evo._configure_zaapmotion()
+
+ # RCS returned OK for all tips, so no config commands should be sent
+ config_calls = [
+ c
+ for c in self.mock_send.call_args_list
+ if any(cfg_cmd in str(c) for cfg_cmd in ["CFE", "CMTBLDC", "WRP"])
+ ]
+ self.assertEqual(len(config_calls), 0)
+
+ async def test_safety_module_sends_spn_sps3(self):
+ await self.evo._setup_safety_module()
+
+ self.mock_send.assert_any_call("O1", command="SPN")
+ self.mock_send.assert_any_call("O1", command="SPS3")
+
+
+class PickUpTipsAirTests(AirEVOTestBase):
+ """Test Air LiHa tip pickup uses correct conversion factors."""
+
+ async def test_tip_pickup_uses_air_speed_factor(self):
+ op = Pickup(
+ resource=self.tip_rack.get_item("A1"),
+ offset=Coordinate.zero(),
+ tip=self.tip_rack.get_tip("A1"),
+ )
+ await self.evo.pick_up_tips([op], use_channels=[0])
+
+ # Check that SEP used Air LiHa speed factor (70 * 213 = 14910)
+ sep_calls = [
+ c
+ for c in self.mock_send.call_args_list
+ if c
+ == call(module="C5", command="SEP", params=[14910, None, None, None, None, None, None, None])
+ ]
+ self.assertEqual(len(sep_calls), 1)
+
+ # Check that PPR used Air LiHa steps/uL (10 * 106.4 = 1064)
+ ppr_calls = [
+ c
+ for c in self.mock_send.call_args_list
+ if c
+ == call(module="C5", command="PPR", params=[1064, None, None, None, None, None, None, None])
+ ]
+ self.assertEqual(len(ppr_calls), 1)
diff --git a/pylabrobot/liquid_handling/liquid_classes/tecan.py b/pylabrobot/liquid_handling/liquid_classes/tecan.py
index 3cdc0751b2a..f817d8d8d4f 100644
--- a/pylabrobot/liquid_handling/liquid_classes/tecan.py
+++ b/pylabrobot/liquid_handling/liquid_classes/tecan.py
@@ -2792,3 +2792,418 @@ def get_liquid_class(
dispense_retract_speed=50,
dispense_retract_offset=0,
)
+mapping[(1.0, 10.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=4,
+ lld_conductivity=1,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=2.14,
+ pmp_character=0,
+ density=1.1,
+ calibration_factor=1.09,
+ calibration_offset=-0.07,
+ aspirate_speed=20.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=15.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=0.1,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=0.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=5.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=600.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=False,
+ dispense_lld_position=0,
+ dispense_lld_offset=0.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # DMSO free dispense DiTi 50 [1.0-10.01 uL]
+
+mapping[(10.01, 50.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=4,
+ lld_conductivity=1,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=2.14,
+ pmp_character=0,
+ density=1.1,
+ calibration_factor=1.04,
+ calibration_offset=0.36,
+ aspirate_speed=50.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=10.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=1.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=0.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=5.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=600.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=False,
+ dispense_lld_position=0,
+ dispense_lld_offset=0.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # DMSO free dispense DiTi 50 [10.01-50.01 uL]
+
+mapping[(0.5, 1.0, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=7,
+ lld_conductivity=2,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=2.14,
+ pmp_character=0,
+ density=1.1,
+ calibration_factor=0.5,
+ calibration_offset=-0.25,
+ aspirate_speed=20.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=10.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=3.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=2.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=5.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=20.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=True,
+ dispense_lld_position=3,
+ dispense_lld_offset=1.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # DMSO wet contact DiTi 50 [0.5-1.0 uL]
+
+mapping[(1.0, 5.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=7,
+ lld_conductivity=2,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=2.14,
+ pmp_character=0,
+ density=1.1,
+ calibration_factor=1.141,
+ calibration_offset=-0.345,
+ aspirate_speed=50.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=8.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=2.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=2.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=5.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=20.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=True,
+ dispense_lld_position=3,
+ dispense_lld_offset=1.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # DMSO wet contact DiTi 50 [1.0-5.01 uL]
+
+mapping[(5.01, 10.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=7,
+ lld_conductivity=2,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=2.14,
+ pmp_character=0,
+ density=1.1,
+ calibration_factor=1.043,
+ calibration_offset=0.214,
+ aspirate_speed=20.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=10.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=2.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=2.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=5.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=20.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=True,
+ dispense_lld_position=3,
+ dispense_lld_offset=1.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # DMSO wet contact DiTi 50 [5.01-10.01 uL]
+
+mapping[(0.5, 5.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=7,
+ lld_conductivity=1,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=1.0,
+ pmp_character=0,
+ density=1.0,
+ calibration_factor=1.1,
+ calibration_offset=0.44,
+ aspirate_speed=20.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=10.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=2.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=2.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=20.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=600.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=False,
+ dispense_lld_position=0,
+ dispense_lld_offset=0.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # Water free dispense DiTi 50 [0.5-5.01 uL]
+
+mapping[(5.01, 10.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=7,
+ lld_conductivity=1,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=1.0,
+ pmp_character=0,
+ density=1.0,
+ calibration_factor=1.085,
+ calibration_offset=0.65,
+ aspirate_speed=20.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=10.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=2.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=2.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=20.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=600.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=False,
+ dispense_lld_position=0,
+ dispense_lld_offset=0.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # Water free dispense DiTi 50 [5.01-10.01 uL]
+
+mapping[(10.01, 50.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass(
+ lld_mode=7,
+ lld_conductivity=1,
+ lld_speed=60.0,
+ lld_distance=4.0,
+ clot_speed=50.0,
+ clot_limit=4.0,
+ pmp_sensitivity=1,
+ pmp_viscosity=1.0,
+ pmp_character=0,
+ density=1.0,
+ calibration_factor=1.052,
+ calibration_offset=0.387,
+ aspirate_speed=70.0,
+ aspirate_delay=400.0,
+ aspirate_stag_volume=0.0,
+ aspirate_stag_speed=20.0,
+ aspirate_lag_volume=8.0,
+ aspirate_lag_speed=70.0,
+ aspirate_tag_volume=2.0,
+ aspirate_tag_speed=20.0,
+ aspirate_excess=0.0,
+ aspirate_conditioning=0.0,
+ aspirate_pinch_valve=False,
+ aspirate_lld=True,
+ aspirate_lld_position=3,
+ aspirate_lld_offset=2.0,
+ aspirate_mix=False,
+ aspirate_mix_volume=100.0,
+ aspirate_mix_cycles=1,
+ aspirate_retract_position=4,
+ aspirate_retract_speed=20.0,
+ aspirate_retract_offset=-5.0,
+ dispense_speed=600.0,
+ dispense_breakoff=400.0,
+ dispense_delay=0.0,
+ dispense_tag=False,
+ dispense_pinch_valve=False,
+ dispense_lld=False,
+ dispense_lld_position=0,
+ dispense_lld_offset=0.0,
+ dispense_touching_direction=0,
+ dispense_touching_speed=10.0,
+ dispense_touching_delay=100.0,
+ dispense_mix=False,
+ dispense_mix_volume=100.0,
+ dispense_mix_cycles=1,
+ dispense_retract_position=1,
+ dispense_retract_speed=50.0,
+ dispense_retract_offset=0.0,
+) # Water free dispense DiTi 50 [10.01-50.01 uL]