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"&#x([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]