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/claude.md b/claude.md index dea76713f79..60c7fe0d49f 100644 --- a/claude.md +++ b/claude.md @@ -1 +1,104 @@ -pretend you are Jeff Dean +# PyLabRobot - Lab Automation Integration + +## Project Overview +PyLabRobot is a hardware-agnostic Python library for lab automation. We are integrating several instruments for automated liquid handling, bulk dispensing, and robotic plate movement. + +## Our Lab Equipment + +### Tecan EVO 150 (Liquid Handler) +- **Backend**: `EVOBackend` from `pylabrobot.liquid_handling.backends.tecan` +- **Frontend**: `LiquidHandler` from `pylabrobot.liquid_handling` +- **Deck**: `EVO150Deck` (45 rails, 1315 x 780 x 765 mm) +- **Connection**: USB (VID=0x0C47, PID=0x4000) +- **Install**: `pip install -e ".[usb]"` + +#### Default Deck Components +- **Plate carrier**: `MP_3Pos` (Tecan part no. 10612604) - 3-position microplate carrier + - Import: `from pylabrobot.resources.tecan.plate_carriers import MP_3Pos` +- **Tip carrier**: `DiTi_3Pos` (Tecan part no. 10613022) - 3-position DiTi carrier + - Import: `from pylabrobot.resources.tecan.tip_carriers import DiTi_3Pos` +- **Tips**: `DiTi_50ul_SBS_LiHa` - 50uL disposable tips for LiHa + - Import: `from pylabrobot.resources.tecan.tip_racks import DiTi_50ul_SBS_LiHa` +- **Plates**: `Eppendorf_96_wellplate_250ul_Vb` - Eppendorf twin.tec 96-well (250uL, V-bottom) + - Import: `from pylabrobot.resources.eppendorf.plates import Eppendorf_96_wellplate_250ul_Vb` + +### Thermo Scientific Multidrop Combi (Bulk Dispenser) +- **Backend**: `MultidropCombiBackend` from `pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi` +- **Frontend**: `BulkDispenser` from `pylabrobot.bulk_dispensers` +- **Connection**: RS232 via USB adapter (specify COM port explicitly) +- **Serial config**: 9600 baud, 8N1, XON/XOFF +- **Install**: `pip install -e ".[serial]"` +- **Plate helpers**: `plate_to_type_index()`, `plate_to_pla_params()` for PLR plate → Multidrop mapping +- **Protocol docs**: `C:\Users\keyser\source\repos\keyser-sila-testing\documentation\Multidrop Combi Remote Control Command Sets (1).pdf` + +### UFACTORY xArm 6 (Robotic Arm) +- **Backend**: `XArm6Backend` from `pylabrobot.arms.xarm6.xarm6_backend` +- **Frontend**: `SixAxisArm` from `pylabrobot.arms.six_axis` +- **Connection**: Ethernet (IP address) +- **Install**: `pip install xarm-python-sdk` + +## Architecture Patterns + +### Legacy Architecture (main branch) +Each device category follows this pattern: +- **Frontend class** (`Machine` subclass) - thin delegation layer with `@need_setup_finished` guards +- **Abstract backend** (`MachineBackend` subclass with `ABCMeta`) - defines the device-type interface +- **Concrete backend** - implements the abstract backend for a specific instrument +- **Chatterbox backend** - prints operations for testing without hardware + +Key base classes: +- `pylabrobot/machines/backend.py` - `MachineBackend(SerializableMixin, ABC)` +- `pylabrobot/machines/machine.py` - `Machine(SerializableMixin, ABC)` + `need_setup_finished` decorator + +### v1b1 Architecture (v1b1 branch) +New capability-based architecture replacing the monolithic backend model: +- **Driver** (`pylabrobot/device.py`) - owns I/O, connection lifecycle (`setup()/stop()`) +- **CapabilityBackend** (`pylabrobot/capabilities/capability.py`) - protocol translation for one concern +- **Capability** - user-facing API with validation, tip tracking, etc. +- **Device** - owns Driver + list of Capabilities, orchestrates lifecycle +- **BackendParams** - typed dataclasses replacing `**kwargs` + +Key interfaces for Tecan EVO migration: +- `PIPBackend` (`pylabrobot/capabilities/liquid_handling/pip_backend.py`) - independent channel pipetting +- `GripperArmBackend` (`pylabrobot/arms/backend.py`) - plate handling arms +- Reference implementation: Hamilton STAR at `pylabrobot/hamilton/liquid_handlers/star/` + +## Branch Strategy + +| Branch | Base | Purpose | +|--------|------|---------| +| `air-liha-backend` | `main` | Legacy Air LiHa backend (WIP, may PR to main) | +| `v1b1-tecan-evo` | `origin/v1b1` | Native v1b1 EVO backend (syringe + Air LiHa + RoMa) | +| `keyser-combined` | `main` | Combined xArm + Multidrop for testing | +| `keyser-multidrop-testing` | `main` | Multidrop Combi backend | +| `keyser-xarm-testing` | `main` | xArm 6 backend | + +### v1b1 Tecan EVO File Structure +``` +pylabrobot/tecan/evo/ + driver.py # TecanEVODriver(Driver) — USB I/O + command protocol + pip_backend.py # EVOPIPBackend(PIPBackend) — syringe LiHa + air_pip_backend.py # AirEVOPIPBackend(EVOPIPBackend) — Air LiHa + roma_backend.py # EVORoMaBackend(GripperArmBackend) — RoMa plate handling + evo.py # TecanEVO(Resource, Device) — composite device + params.py # BackendParams dataclasses + errors.py # TecanError + firmware/ # Extracted firmware command wrappers (LiHa, RoMa, EVOArm) +``` + +Legacy EVO stays at: `pylabrobot/legacy/liquid_handling/backends/tecan/` + +## Air LiHa (ZaapMotion) Key Facts +- ZaapMotion controllers boot into bootloader mode after power cycle +- Must send `T2{0-7}X` (exit boot) + 33 motor config commands per tip before PIA +- Plunger conversion: 106.4 steps/uL (vs 3 for syringe), 213 speed factor (vs 6) +- Force mode: `SFR133120`+`SFP1` before plunger ops, `SFR3752`+`SDP1400` after +- Investigation details: `keyser-testing/AirLiHa_Investigation.md` + +## Development +- Venv: `.venv/` in project root +- Install all deps: `pip install -e ".[serial,usb]"` +- Tests: `python -m pytest pylabrobot/bulk_dispensers/ -v` +- Lint: `ruff check`, Format: `ruff format`, Types: `mypy pylabrobot --check-untyped-defs` +- Abstract interfaces use microliters (float); backends convert to instrument-specific units +- Tecan Z coordinates: 0 = deck surface, z_range (~2100) = top/home diff --git a/keyser-testing/PR_cleanup_checklist.md b/keyser-testing/PR_cleanup_checklist.md new file mode 100644 index 00000000000..63d7bb29d92 --- /dev/null +++ b/keyser-testing/PR_cleanup_checklist.md @@ -0,0 +1,91 @@ +# PR Cleanup Checklist + +## Before submitting PRs, strip `keyser-testing/` and keep only `pylabrobot/` changes. + +--- + +## `air-liha-backend` → PR to `main` + +### Files to INCLUDE in PR: +- [x] `pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` (new) +- [x] `pylabrobot/liquid_handling/backends/tecan/__init__.py` (1 line added) +- [x] `pylabrobot/liquid_handling/liquid_classes/tecan.py` (8 entries appended) + +### Files to EXCLUDE from PR: +- [ ] `keyser-testing/` — entire directory (test scripts, USB captures, manuals, DLLs) +- [ ] `claude.md` — local project instructions +- [ ] `.claude/` — Claude Code settings +- [ ] Any `taught_positions.json`, `labware_edits.json` + +### Pre-PR checks: +- [ ] `ruff check pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` +- [ ] `ruff format --check pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` +- [ ] `mypy pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py --check-untyped-defs` +- [ ] Existing EVO tests still pass: `pytest pylabrobot/liquid_handling/backends/tecan/EVO_tests.py` +- [ ] Remove debug print statements from `air_evo_backend.py` +- [ ] Remove temporary X offset (`x += 60`) or document as TODO +- [ ] Add docstring to `AirEVOBackend` explaining ZaapMotion requirements + +### How to create clean PR branch: +```bash +git checkout air-liha-backend +git checkout -b air-liha-pr +git rm -r --cached keyser-testing/ +git rm --cached claude.md +echo "keyser-testing/" >> .gitignore +git commit -m "Remove test artifacts for clean PR" +``` + +--- + +## `v1b1-tecan-evo` → PR to `v1b1` + +### Files to INCLUDE in PR: +- [x] `pylabrobot/tecan/__init__.py` (new) +- [x] `pylabrobot/tecan/evo/__init__.py` (new) +- [x] `pylabrobot/tecan/evo/driver.py` (new) +- [x] `pylabrobot/tecan/evo/pip_backend.py` (new) +- [x] `pylabrobot/tecan/evo/air_pip_backend.py` (new) +- [x] `pylabrobot/tecan/evo/roma_backend.py` (new) +- [x] `pylabrobot/tecan/evo/evo.py` (new) +- [x] `pylabrobot/tecan/evo/errors.py` (new) +- [x] `pylabrobot/tecan/evo/firmware/__init__.py` (new) +- [x] `pylabrobot/tecan/evo/firmware/arm_base.py` (new) +- [x] `pylabrobot/tecan/evo/firmware/liha.py` (new) +- [x] `pylabrobot/tecan/evo/firmware/roma.py` (new) +- [x] `pylabrobot/tecan/evo/tests/__init__.py` (new) +- [x] `pylabrobot/tecan/evo/tests/driver_tests.py` (new) +- [x] `pylabrobot/legacy/liquid_handling/liquid_classes/tecan.py` (8 entries appended) + +### Files to EXCLUDE from PR: +- [ ] `keyser-testing/` — entire directory +- [ ] `claude.md` — local project instructions +- [ ] `.claude/` — Claude Code settings + +### Pre-PR checks: +- [ ] `ruff check pylabrobot/tecan/` +- [ ] `ruff format --check pylabrobot/tecan/` +- [ ] `mypy pylabrobot/tecan/ --check-untyped-defs` +- [ ] `pytest pylabrobot/tecan/evo/tests/ -v` +- [ ] Legacy EVO tests still pass: `pytest pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py` +- [ ] Remove any debug print statements +- [ ] Add unit tests for pip_backend, air_pip_backend, roma_backend +- [ ] Verify `TecanEVO` constructs correctly with all config combinations + +### How to create clean PR branch: +```bash +git checkout v1b1-tecan-evo +git checkout -b v1b1-tecan-evo-pr +git rm -r --cached keyser-testing/ +git rm --cached claude.md +echo "keyser-testing/" >> .gitignore +git commit -m "Remove test artifacts for clean PR" +``` + +--- + +## Common notes: +- `keyser-testing/` stays on our fork branches for ongoing development/testing +- The PR branches are ephemeral — created just for the PR, then deleted after merge +- The `labware_library.py` is local to our lab — NOT included in PRs +- USB captures, manuals, DLLs, and EVOware logs are investigation artifacts — NOT for upstream diff --git a/keyser-testing/completed-plans/legacy_firmware_feature_plan.md b/keyser-testing/completed-plans/legacy_firmware_feature_plan.md new file mode 100644 index 00000000000..1342dbfa911 --- /dev/null +++ b/keyser-testing/completed-plans/legacy_firmware_feature_plan.md @@ -0,0 +1,274 @@ +# Tecan EVO Legacy — Firmware Feature Development Plan + +**Status: MOSTLY COMPLETE** (Steps 1-4, 5c, 6b, 6c done; Steps 5a, 5b, 6a deferred — require LiquidHandlerBackend interface changes) +**Implemented:** 2026-03-30 +**Branch:** `air-liha-backend` — commit `7b1c48a29` + +## Context + +Parallel to the v1b1 plan, this covers the same firmware feature gaps for the legacy `air-liha-backend` branch. The legacy architecture differs: arm firmware classes (LiHa, RoMa, EVOArm) live inside `EVO_backend.py`, the abstract interface is `LiquidHandlerBackend`, and vendor-specific params use `**backend_kwargs` rather than `BackendParams` dataclasses. + +**Branch:** `air-liha-backend` (based on `main`) + +### Key Architectural Differences from v1b1 + +| Aspect | v1b1 | Legacy | +|--------|------|--------| +| Firmware wrappers | `firmware/liha.py`, `firmware/roma.py`, `firmware/arm_base.py` | Inline in `EVO_backend.py` (lines 892-1393) | +| Backend base class | `PIPBackend` + `GripperArmBackend` | `LiquidHandlerBackend` (monolithic) | +| Operation types | `Aspiration`, `Dispense` | `SingleChannelAspiration`, `SingleChannelDispense` | +| Backend params | `BackendParams` dataclass | `**backend_kwargs` dict | +| Backend files | `pip_backend.py`, `air_pip_backend.py`, `roma_backend.py` | `EVO_backend.py`, `air_evo_backend.py` | +| Resource handling | Separate `GripperArmBackend` | Same backend: `pick_up_resource()`, `drop_resource()` | + +--- + +## Step 1: Firmware Layer Cleanup — Wrap Raw Commands ✅ + +Same commands as v1b1, but added as methods on the inline arm classes. + +### 1a. Add to `EVOArm` class (in `EVO_backend.py`) + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `read_error_register(param=0) -> str` | `REE` | Read axis error state | +| `position_init_all()` | `PIA` | Initialize all axes | +| `position_init_bus()` | `PIB` | Initialize bus (MCA) | +| `set_bus_mode(mode)` | `BMX` | Set bus mode (2=normal) | +| `bus_module_action(p1,p2,p3)` | `BMA` | Halt all axes | + +### 1b. Add to `LiHa` class (in `EVO_backend.py`) + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `position_plunger_absolute(positions)` | `PPA` | Move plunger to absolute position | +| `set_disposable_tip_params(mode, z_discard, z_retract)` | `SDT` | DiTi discard parameters | + +### 1c. Update backends to use new methods + +- **`EVOBackend.setup()`** — replace raw `PIA`/`PIB`/`BMX` calls with `EVOArm` methods +- **`AirEVOBackend._is_initialized()`** — replace raw `REE0` with `arm.read_error_register(0)` +- **`AirEVOBackend.drop_tips()`** — replace raw `PPA`/`SDT` strings with `self.liha.position_plunger_absolute()` / `self.liha.set_disposable_tip_params()` +- **`EVOBackend` RoMa setup** — replace raw `REE`/`PIA`/`BMX` with `EVOArm` calls + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/EVO_backend.py` (EVOArm, LiHa classes + setup methods) +- `pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` + +--- + +## Step 2: New Firmware Commands ✅ + +### Add to `LiHa` class (in `EVO_backend.py`) + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `read_plunger_positions() -> List[int]` | `RPP` | Query current plunger state | +| `read_z_after_liquid_detection() -> List[int]` | `RVZ` | Get detected liquid Z heights | +| `read_tip_status() -> List[bool]` | `RTS` | Which tips are mounted | +| `position_absolute_z_bulk(z)` | `PAZ` | Fast bulk Z move (vs slow `MAZ`) | + +**Note:** `RTS` response format needs hardware validation. + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/EVO_backend.py` (LiHa class) + +--- + +## Step 3: ZaapMotion Firmware Abstraction ✅ + +### New class: `ZaapMotion` (in `air_evo_backend.py` or separate file) + +Unlike v1b1 where we create `firmware/zaapmotion.py`, in the legacy branch this class can either: +- **(Option A)** Live in `air_evo_backend.py` alongside `AirEVOBackend` — simpler, keeps everything together +- **(Option B)** Create `pylabrobot/liquid_handling/backends/tecan/zaapmotion.py` — cleaner separation + +**Recommended: Option A** (matches the legacy pattern of keeping firmware code in backend files) + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `exit_boot_mode(tip)` | `T2{tip}X` | Exit bootloader | +| `read_firmware_version(tip) -> str` | `T2{tip}RFV` | Check boot/app mode | +| `read_config_status(tip)` | `T2{tip}RCS` | Check if configured | +| `set_force_ramp(tip, value)` | `T2{tip}SFR{v}` | Force ramp control | +| `set_force_mode(tip)` | `T2{tip}SFP1` | Enable force positioning | +| `set_default_position(tip, value)` | `T2{tip}SDP{v}` | Set idle position | +| `configure_motor(tip, cmd)` | `T2{tip}{cmd}` | Send config command | +| `set_sdo(param)` | `T23SDO{param}` | SDO object write | + +### Refactor `air_evo_backend.py` +- Add `self.zaap: Optional[ZaapMotion]` attribute +- Refactor `_configure_zaapmotion()` to use `ZaapMotion` methods +- Refactor `_zaapmotion_force_on()` / `_zaapmotion_force_off()` +- Refactor SDO call in `setup()` + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` + +--- + +## Step 4: Mixing & Blow-out ✅ + +### 4a. No `params.py` needed — use `**backend_kwargs` + +Legacy uses `**backend_kwargs` for vendor-specific params. Tecan-specific options will be passed as keyword arguments: + +```python +# Usage example: +await lh.aspirate( + wells, [50], use_channels=[0], + mix=[Mix(20, 3, 100)], + liquid_detection_proc=3, # via **backend_kwargs + liquid_detection_sense=0, # via **backend_kwargs +) + +await lh.dispense( + wells, [50], use_channels=[0], + blow_out_air_volume=[5.0], + tip_touch=True, # via **backend_kwargs + tip_touch_offset_y=1.0, # via **backend_kwargs +) +``` + +### 4b. Add mixing to `EVOBackend` + +Add `_perform_mix(mix, use_channels)` to `EVOBackend`: +- Set valve to outlet (PVL=0), set plunger speed from `mix.flow_rate * SPEED_FACTOR` +- Loop `mix.repetitions` times: PPR(+steps), PPR(-steps) at current Z position +- Call after aspirate/dispense actions when `op.mix is not None` + +In `EVOBackend.aspirate()` — add after trailing airgap: +```python +for i, (op, ch) in enumerate(zip(ops, use_channels)): + if op.mix is not None: + await self._perform_mix(op.mix, [ch for ch, o in zip(use_channels, ops) if o.mix]) + break +``` + +Same in `EVOBackend.dispense()`. + +Override in `AirEVOBackend` to wrap with force mode: +```python +async def _perform_mix(self, mix, use_channels): + await self._zaapmotion_force_on() + await super()._perform_mix(mix, use_channels) + await self._zaapmotion_force_off() +``` + +### 4c. Add blow-out to `EVOBackend` + +Add `_perform_blow_out(ops, use_channels)`: +- For channels with `blow_out_air_volume > 0`: set valve outlet, push plunger negative +- Call at end of `dispense()` + +Override in `AirEVOBackend` to wrap with force mode. + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/EVO_backend.py` — add `_perform_mix`, `_perform_blow_out` +- `pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` — override with force mode + +--- + +## Step 5: Tip Touch, LLD Config, Tip Presence — PARTIAL (5c done, 5a/5b deferred) + +### 5a. Tip touch in `EVOBackend.dispense()` — DEFERRED +- Requires `**backend_kwargs` to be plumbed through `LiquidHandlerBackend.dispense()` abstract interface +- The base class does not currently pass kwargs to backend methods +- Deferred: would require changes to shared base classes in `pylabrobot/liquid_handling/backends/backend.py` + +### 5b. LLD config in `EVOBackend.aspirate()` — DEFERRED +- Same issue: requires `**backend_kwargs` plumbing through `LiquidHandlerBackend.aspirate()` + +### 5c. `request_tip_presence()` in `EVOBackend` ✅ +- Implemented using `self.liha.read_tip_status()` (from Step 2) + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/EVO_backend.py` + +--- + +## Step 6: RoMa Enhancements — PARTIAL (6b/6c done, 6a deferred) + +### 6a. Configurable speed profiles — DEFERRED +- Same `**backend_kwargs` plumbing issue as Step 5a/5b +- `pick_up_resource()` / `drop_resource()` don't receive kwargs from `LiquidHandler` + +### 6b. Post-grip plate verification ✅ +- After `grip_plate()`: query `report_g_param(0)`, log warning if gripper didn't close + +### 6c. Configurable park position ✅ +- Added `_roma_park_position` class attribute, used in `_park_roma()` + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/EVO_backend.py` + +--- + +## Step 7: System-Level — DEFERRED (see v1b1_migration_plan.md Phase 6d) + +- Error recovery via `read_error_register()` + re-init +- Instrument status reporting +- Safety module monitoring + +### Files modified +- `pylabrobot/liquid_handling/backends/tecan/EVO_backend.py` +- `pylabrobot/liquid_handling/backends/tecan/air_evo_backend.py` + +--- + +## Implementation Order & Dependencies + +``` +Step 1 ──┬── Step 3 ── Step 4 ── Step 5 + │ │ +Step 2 ──┘ Step 7 + │ + └── Step 6 +``` + +Same as v1b1. Steps 1 and 2 can run in parallel. Step 6 only depends on Step 1. + +--- + +## Key Difference: Fewer Files to Touch + +Unlike v1b1 (8+ files), the legacy architecture concentrates changes in just 2 files: +- `EVO_backend.py` — firmware classes + EVOBackend + RoMa handling +- `air_evo_backend.py` — AirEVOBackend + ZaapMotion + +This means less structural work but more careful editing within large files. + +--- + +## Verification + +### Unit Tests +- Update `EVO_tests.py`: verify firmware methods send correct command strings +- Update `air_evo_tests.py`: verify ZaapMotion refactor, force mode wrapping +- Add mix/blow-out tests: verify PPR command sequences +- All existing tests must pass unchanged + +### Hardware Testing +- Same as v1b1: `RTS` format, `PAZ` units, mix timing, blow-out limits + +### Smoke Test Sequence +```python +lh = LiquidHandler(backend=AirEVOBackend(), deck=deck) +await lh.setup() +await lh.pick_up_tips(tips) +await lh.aspirate(wells[:2], [50, 50], mix=[Mix(20, 3, 100), Mix(20, 3, 100)]) +await lh.dispense(wells[2:4], [50, 50], blow_out_air_volume=[5.0, 5.0], + tip_touch=True, tip_touch_offset_y=1.0) +await lh.drop_tips(waste) +await lh.stop() +``` + +--- + +## Cross-Branch Sync Notes + +When implementing, keep the firmware command sequences identical between branches: +- Same method names on LiHa/RoMa/ZaapMotion classes +- Same command strings and parameter ordering +- Same force mode wrapping pattern for Air LiHa +- Differences are only in how the backends consume them (BackendParams vs **backend_kwargs, separate files vs inline) diff --git a/keyser-testing/completed-plans/v1b1_firmware_feature_plan.md b/keyser-testing/completed-plans/v1b1_firmware_feature_plan.md new file mode 100644 index 00000000000..257bf4591fa --- /dev/null +++ b/keyser-testing/completed-plans/v1b1_firmware_feature_plan.md @@ -0,0 +1,240 @@ +# Tecan EVO v1b1 — Firmware Feature Development Plan + +**Status: COMPLETE** (Steps 1-6 implemented, Step 7 deferred as future work) +**Implemented:** 2026-03-30 +**Branch:** `v1b1-tecan-evo` — commit `355c67361` + +## Context + +The EVO firmware supports many commands that are either not wrapped in our firmware layer or not used by the backends at all. This plan closes those gaps systematically: first cleaning up raw `send_command` calls, then adding new firmware wrappers, abstracting ZaapMotion commands, and finally implementing higher-level features (mixing, blow-out, tip touch, LLD config, tip presence). + +**Branch:** `v1b1-tecan-evo` (based on `origin/v1b1`) + +--- + +## Step 1: Firmware Layer Cleanup — Wrap Raw Commands ✅ + +Replace raw `send_command` string calls with typed firmware wrapper methods. + +### 1a. Add to `arm_base.py` + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `read_error_register(param=0) -> str` | `REE` | Read axis error state (`@`=ok, `G`=not init) | +| `position_init_all()` | `PIA` | Initialize all axes | +| `position_init_bus()` | `PIB` | Initialize bus (MCA) | +| `set_bus_mode(mode)` | `BMX` | Set bus mode (2=normal) | +| `bus_module_action(p1,p2,p3)` | `BMA` | Halt all axes (0,0,0) | + +### 1b. Add to `liha.py` + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `position_plunger_absolute(positions)` | `PPA` | Move plunger to absolute position | +| `set_disposable_tip_params(mode, z_discard, z_retract)` | `SDT` | DiTi discard parameters | + +### 1c. Update backends to use new methods + +- **`pip_backend.py` `_setup_arm()`** — replace raw `PIB`/`PIA`/`BMX` with `EVOArm` wrapper calls +- **`air_pip_backend.py` `_is_initialized()`** — replace raw `REE0` with `arm.read_error_register(0)` +- **`air_pip_backend.py` `drop_tips()`** — replace raw `PPA`/`SDT` strings with `self.liha.position_plunger_absolute()` / `self.liha.set_disposable_tip_params()` +- **`roma_backend.py` `_on_setup()`** — replace raw `REE`/`PIA`/`BMX` with `EVOArm` wrapper calls +- **`roma_backend.py` `halt()`** — replace raw `BMA` with `self.roma.bus_module_action(0,0,0)` + +### Files modified +- `pylabrobot/tecan/evo/firmware/arm_base.py` +- `pylabrobot/tecan/evo/firmware/liha.py` +- `pylabrobot/tecan/evo/pip_backend.py` +- `pylabrobot/tecan/evo/air_pip_backend.py` +- `pylabrobot/tecan/evo/roma_backend.py` + +--- + +## Step 2: New Firmware Commands ✅ + +Add wrappers for commands we haven't used yet. + +### Add to `liha.py` + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `read_plunger_positions() -> List[int]` | `RPP` | Query current plunger state | +| `read_z_after_liquid_detection() -> List[int]` | `RVZ` | Get detected liquid Z heights | +| `read_tip_status() -> List[bool]` | `RTS` | Which tips are mounted | +| `position_absolute_z_bulk(z) ` | `PAZ` | Fast bulk Z move (vs slow `MAZ`) | + +**Note:** `RTS` response format needs hardware validation — implement defensively. + +### Files modified +- `pylabrobot/tecan/evo/firmware/liha.py` + +--- + +## Step 3: ZaapMotion Firmware Abstraction ✅ + +Create `firmware/zaapmotion.py` to replace ~50 raw `T2{tip}` commands in `air_pip_backend.py`. + +### New class: `ZaapMotion` + +| Method | Firmware Cmd | Purpose | +|--------|-------------|---------| +| `exit_boot_mode(tip)` | `T2{tip}X` | Exit bootloader | +| `read_firmware_version(tip) -> str` | `T2{tip}RFV` | Check boot/app mode | +| `read_config_status(tip)` | `T2{tip}RCS` | Check if configured | +| `set_force_ramp(tip, value)` | `T2{tip}SFR{v}` | Force ramp (133120 active, 3752 idle) | +| `set_force_mode(tip)` | `T2{tip}SFP1` | Enable force positioning | +| `set_default_position(tip, value)` | `T2{tip}SDP{v}` | Set idle position (1400 default) | +| `configure_motor(tip, cmd)` | `T2{tip}{cmd}` | Send one of 33 config commands | +| `set_sdo(param)` | `T23SDO{param}` | SDO object write | + +### Refactor `air_pip_backend.py` +- Add `self.zaap: Optional[ZaapMotion]` attribute +- Refactor `_configure_zaapmotion()` to use `ZaapMotion` methods +- Refactor `_zaapmotion_force_on()` / `_zaapmotion_force_off()` — preserve exact command ordering (all SFR first, then all SFP/SDP) +- Refactor SDO call in `_on_setup()` + +### Files created +- `pylabrobot/tecan/evo/firmware/zaapmotion.py` + +### Files modified +- `pylabrobot/tecan/evo/firmware/__init__.py` — add `ZaapMotion` export +- `pylabrobot/tecan/evo/air_pip_backend.py` — refactor to use `ZaapMotion` + +--- + +## Step 4: Mixing & Blow-out ✅ + +### 4a. Create `params.py` with EVO-specific BackendParams + +```python +@dataclass(frozen=True) +class TecanPIPParams(BackendParams): + liquid_detection_proc: Optional[int] = None # SDM proc (default 7) + liquid_detection_sense: Optional[int] = None # SDM sense (default 1) + tip_touch: bool = False + tip_touch_offset_y: float = 1.0 # mm toward wall +``` + +```python +@dataclass(frozen=True) +class TecanRoMaParams(BackendParams): + speed_x: Optional[int] = None # 1/10 mm/s + speed_y: Optional[int] = None + speed_z: Optional[int] = None + speed_r: Optional[int] = None # 1/10 deg/s + accel_y: Optional[int] = None # 1/10 mm/s^2 + accel_r: Optional[int] = None +``` + +### 4b. Add mixing to `pip_backend.py` + +Add `_perform_mix(mix, use_channels)`: +- Set valve to outlet (PVL=0), set plunger speed from `mix.flow_rate * SPEED_FACTOR` +- Loop `mix.repetitions` times: PPR(+steps), PPR(-steps) at current Z position +- Call after aspirate/dispense actions when `op.mix is not None` + +Override in `air_pip_backend.py` to wrap with force mode. + +### 4c. Add blow-out to `pip_backend.py` + +Add `_perform_blow_out(ops, use_channels)`: +- For channels with `blow_out_air_volume > 0`: set valve outlet, push plunger by `-blow_out * STEPS_PER_UL` +- Call at end of `dispense()` + +Override in `air_pip_backend.py` to wrap with force mode. + +### Files created +- `pylabrobot/tecan/evo/params.py` + +### Files modified +- `pylabrobot/tecan/evo/pip_backend.py` — add `_perform_mix`, `_perform_blow_out` +- `pylabrobot/tecan/evo/air_pip_backend.py` — override with force mode wrapping + +--- + +## Step 5: Tip Touch, LLD Config, Tip Presence ✅ + +### 5a. Tip touch in `pip_backend.py` `dispense()` +- Check `backend_params` for `TecanPIPParams(tip_touch=True)` +- After dispense: brief PAA Y-offset move, then PAA back + +### 5b. LLD config in `pip_backend.py` `aspirate()` +- If `TecanPIPParams.liquid_detection_proc` or `liquid_detection_sense` set, use those instead of liquid class defaults for `SDM` call + +### 5c. `request_tip_presence()` in `pip_backend.py` +- Implement optional PIPBackend method using `self.liha.read_tip_status()` (from Step 2) + +### Files modified +- `pylabrobot/tecan/evo/pip_backend.py` + +--- + +## Step 6: RoMa Enhancements ✅ (partial — drop_at_carrier speeds deferred) + +### 6a. Configurable speed profiles +- In `pick_up_from_carrier()` and `drop_at_carrier()`: check `backend_params` for `TecanRoMaParams` +- Apply speed overrides to SFX/SFY/SFZ/SFR calls, falling back to current hardcoded defaults + +### 6b. Post-grip plate verification +- After `grip_plate()`: query `report_g_param(0)`, log warning if gripper didn't close + +### 6c. Configurable park position +- Add `park_position` tuple to `__init__`, use in `park()` instead of hardcoded values + +### Files modified +- `pylabrobot/tecan/evo/roma_backend.py` + +--- + +## Step 7: System-Level — DEFERRED (see v1b1_migration_plan.md Phase 6d) + +- Error recovery via `read_error_register()` + re-init +- Instrument status aggregation (`RPP` + `RTS` + `REE` → status dict) +- Safety module monitoring during operations + +### Files modified +- `pylabrobot/tecan/evo/pip_backend.py` +- `pylabrobot/tecan/evo/evo.py` + +--- + +## Implementation Order & Dependencies + +``` +Step 1 ──┬── Step 3 ── Step 4 ── Step 5 + │ │ +Step 2 ──┘ Step 7 + │ + └── Step 6 +``` + +Steps 1 and 2 can run in parallel. Step 6 only depends on Step 1. + +--- + +## Verification + +### Unit Tests +- Existing tests must pass after each step (commands sent are identical, just routed through wrappers) +- New tests for each firmware method: mock `send_command`, verify command string and params +- Mix tests: verify N pairs of PPR commands sent +- Blow-out tests: verify extra negative PPR after dispense MTR +- Tip presence: mock RTS response, verify boolean list + +### Hardware Testing +- `RTS` response format needs validation on real EVO +- `PAZ` (fast bulk Z) — verify units match `MAZ` +- Mix timing: verify valve/plunger sequencing doesn't cause bubbles +- Blow-out plunger limits: verify plunger doesn't exceed range at 0 position + +### Smoke Test Sequence +```python +evo = TecanEVO(deck=deck, diti_count=8, air_liha=True) +await evo.setup() +await evo.pip.pick_up_tips(tips, use_channels=[0,1]) +await evo.pip.aspirate(wells[:2], [50, 50], mix=[Mix(20, 3, 100), Mix(20, 3, 100)]) +await evo.pip.dispense(wells[2:4], [50, 50], blow_out_air_volume=[5.0, 5.0], + backend_params=TecanPIPParams(tip_touch=True)) +await evo.pip.drop_tips(waste, use_channels=[0,1]) +await evo.stop() +``` diff --git a/keyser-testing/hardware_testing_checklist.md b/keyser-testing/hardware_testing_checklist.md new file mode 100644 index 00000000000..9a7608fd2a5 --- /dev/null +++ b/keyser-testing/hardware_testing_checklist.md @@ -0,0 +1,172 @@ +# Tecan EVO Hardware Testing Checklist + +## Pre-Test Setup + +### Equipment Required +- [x] EVO 150 powered on +- [x] USB cable connected to pylabrobot PC +- [x] EVOware PC disconnected from USB (only one client at a time) +- [x] DiTi 50uL SBS tips loaded (position 3 on MP_3Pos at rail 16) +- [x] Eppendorf 96-well plate with water in column 1 (position 1) +- [x] Empty Eppendorf 96-well plate (position 2) +- [x] `.venv` activated, `pip install -e ".[usb]"` done + +### Software +- [x] `v1b1-tecan-evo` branch checked out +- [x] `keyser-testing/labware_library.py` has taught Z values from jog tool + +--- + +## Test 1: Initialization (Cold Boot) ✅ PASSED + +**Script:** `keyser-testing/test_v1b1_init.py` +**Date:** 2026-03-30 + +| Step | Expected | Pass? | +|------|----------|-------| +| USB connection | "USB connected" | [x] ~3s | +| ZaapMotion boot exit | All 8 tips XP2000/ZMA | [x] | +| ZaapMotion motor config | 33 commands × 8 tips OK | [x] | +| Safety module (SPN/SPS3) | OK | [x] | +| PIA (all axes) | REE0 = `@@@@@@@@@@@` | [x] | +| RoMa init + park | OK (~56s first time) | [x] | +| LiHa range queries | num_channels=8, z_range~2100 | [x] | +| Plunger init | PID, PVL, PPR sequence completes | [x] | + +--- + +## Test 2: Initialization (Warm Reconnect) ✅ PASSED + +**Date:** 2026-03-30 + +| Step | Expected | Pass? | +|------|----------|-------| +| REE0 check | Not "A" or "G" → skip full init | [x] | +| RoMa REE check | `@@@@@` → skip RoMa PIA | [x] | +| Quick setup | Channel count + ranges loaded fast | [x] | +| Total time | **3.4 seconds** (vs ~60s for full init) | [x] | + +### Notes +- Fixed RoMa warm reconnect: now checks REE before PIA (was 56s, now 0.0s) +- Fixed USB buffer drain: uses 1s packet timeout instead of 30s +- Fixed `_is_initialized`: REE0 response cast to str (was int for some states) + +--- + +## Test 3: Tip Pickup — IN PROGRESS + +**Script:** `keyser-testing/test_v1b1_pipette.py` + +### Z-Calibration Done +- Taught positions recorded via `jog_ui.py`: + - tip top: X=3893, Y=146, Z=780 + - plate top-dest: X=3883, Y=1087, Z=260 + - plate top-source: X=3878, Y=2047, Z=295 +- Labware library updated with taught Z values +- Tip rack: z_start=850, z_max=550 (search range 300) + +### X/Y Calibration Done +- Per-labware calibration offsets added: + - Tips: x_offset=+62 (+6.2mm), y_offset=+18 (+1.8mm) + - Plates: x_offset=+103 (+10.3mm), y_offset=-6 (-0.6mm) +- Applied via `_apply_calibration_offsets()` in all operations + +### Current Status +- Channels move to approximately correct X/Y position +- AGT (tip search) executes but returns error 26 "Tip not mounted" +- Likely issue: Z search range or tip engagement depth needs tuning +- **TODO**: Fine-tune X/Y offset and Z search parameters + +| Step | Expected | Pass? | +|------|----------|-------| +| X/Y positioning | Channels aligned over tip column | [~] Close but needs fine-tuning | +| Z approach | Channels descend to tips | [x] AGT executes | +| Tip engagement | Force feedback engages all 8 tips | [ ] Error 26 | +| Z retract | Channels lift with tips mounted | [ ] | +| RTS check | Tip status = 255 (all mounted) | [ ] | + +--- + +## Test 4: Tip Drop — NOT STARTED + +Blocked by Test 3 (need tips mounted first). + +--- + +## Test 5: Aspirate — NOT STARTED + +Blocked by Test 3. + +### Preparation Done +- Aspirate/dispense Z adjusted for mounted tip length + (tip_ext = total_tip_length * 10 - 50 nesting = 531 units) +- Plate z_start=300, z_dispense=200 (taught from bare channel positions) +- Y-spacing fix in place (uses plate.item_dy not well.size_y) + +--- + +## Test 6: Dispense — NOT STARTED + +Blocked by Test 5. + +--- + +## Test 7: Full Cycle — NOT STARTED + +| Step | Pass? | Notes | +|------|-------|-------| +| Init | [x] | Cold + warm both work | +| Tip pickup | [ ] | Error 26, needs Z/X tuning | +| Aspirate | [ ] | | +| Dispense | [ ] | | +| Tip drop | [ ] | | +| Clean stop | [x] | | + +--- + +## Test 8: RoMa Plate Handling — NOT STARTED + +RoMa init works (cold + warm). Plate handling not yet tested. + +--- + +## Z-Calibration Procedure + +Use `keyser-testing/jog_ui.py` (web UI at http://localhost:5050): + +1. **Tip rack z_start**: Jog to just above tip tops, teach `z_start` for `tips` +2. **Tip rack z_max**: Jog to bottom of tip search range, teach `z_max` for `tips` +3. **Plate z_start**: Jog bare channel to plate top surface, teach `z_start` for plate +4. **Plate z_dispense**: Jog to dispense height, teach `z_dispense` for plate +5. **Plate z_max**: Jog to maximum depth, teach `z_max` for plate + +Taught positions saved in `keyser-testing/taught_positions.json`. +Labware edits saved in `keyser-testing/labware_edits.json`. + +### Important Z Notes +- Tecan Z coordinate system: 0 = deck surface, z_range (~2100) = top/home +- Taught positions are measured with **bare channels** (no tip mounted) +- For aspirate/dispense with tips: Z target = plate.z_start + tip_extension + - tip_extension = total_tip_length * 10 - nesting_depth (50 units / 5mm) +- AGT z_start/z_max are used directly (no tip extension needed — tips not yet mounted) + +--- + +## Tools Available + +| Tool | Purpose | +|------|---------| +| `keyser-testing/jog_ui.py` | Web UI for jogging, teaching, labware inspection | +| `keyser-testing/jog_and_teach.py` | CLI jog/teach tool | +| `keyser-testing/tips_off.py` | Emergency tip removal | +| `keyser-testing/test_v1b1_init.py` | Init test with timing | +| `keyser-testing/test_v1b1_pipette.py` | Full pipetting cycle test | + +--- + +## Known Issues / TODO + +1. **Tip pickup X/Y still slightly off** — calibration offsets close but may need further refinement +2. **Tip pickup Z** — AGT returns error 26 "Tip not mounted", search range or depth needs adjustment +3. **Aspirate/Dispense Z** — not yet tested, tip extension calculation needs hardware validation +4. **X offset root cause** — systematic 6-10mm offset between resource model and physical positions, currently compensated with per-labware offsets. Root cause may be deck origin definition or carrier off_x interpretation. diff --git a/keyser-testing/jog_and_teach.py b/keyser-testing/jog_and_teach.py new file mode 100644 index 00000000000..5220bdc1282 --- /dev/null +++ b/keyser-testing/jog_and_teach.py @@ -0,0 +1,367 @@ +"""Interactive jog, teach, and labware editor for Tecan EVO Air LiHa. + +Features: + - Jog X/Y/Z in configurable step sizes + - Teach and record positions (tip z_start, plate z_start, etc.) + - Display current deck layout with carriers, plates, tip racks + - Edit labware Z definitions (z_start, z_dispense, z_max) from taught positions + - Save/load taught positions and edited labware to JSON + +Commands: + Movement: + x+/x- Jog X axis + y+/y- Jog Y axis + z+/z- Jog Z axis (z+ = toward deck) + s Set step size in mm (s1, s5, s10, s0.5) + + Position: + p Print current position + r Record position with label + goto