# Protocol Generation Guide

This document is a prompt-helper for LLMs generating pylabrobot automation scripts.
When a biologist describes a protocol in natural language, use this guide to produce
a standalone Python script that runs on our lab's instruments.

## Workflow

1. The user describes their protocol in plain language.
2. Ask clarifying questions about: volumes, number of replicates, source/destination
   layout, liquid types, and any special handling (mixing, temperature, etc.).
3. Generate a complete standalone script following the template below.
4. Review the script for safety violations before presenting it.

---

## Available Instruments

```python
INSTRUMENTS = {
  "liquid_handler": {
    "name": "Tecan EVO 150",
    "backend": "AirEVOPIPBackend",
    "channels": 8,
    "tip_sizes": [50],  # uL, available tip racks
    "volume_range": {"min": 1.0, "max": 50.0},  # uL per channel
    "capabilities": ["aspirate", "dispense", "pick_up_tips", "drop_tips"],
  },
  "bulk_dispenser": {
    "name": "Thermo Multidrop Combi",
    "backend": "MultidropCombiBackend",
    "connection": "RS232 via USB (specify COM port)",
    "volume_range": {"min": 0.5, "max": 2500.0},  # uL per well
    "capabilities": ["dispense", "prime", "empty", "shake"],
    "notes": "Dispenses same volume to all wells in a column. Best for filling plates.",
  },
}
```

Update this section as new instruments are added.

---

## Standard Deck Layout

The EVO 150 deck uses an MP_3Pos carrier at rail 16 with three positions:

| Position | Labware | Default Use |
|----------|---------|-------------|
| 0 (front) | Eppendorf 96-well 250uL V-bottom | Source plate |
| 1 (middle) | Eppendorf 96-well 250uL V-bottom | Destination plate |
| 2 (rear) | DiTi 50uL SBS tip rack | Disposable tips |

```python
# Standard deck setup — include verbatim in every script
from labware_library import (
  DiTi_50ul_SBS_LiHa_Air,
  Eppendorf_96_wellplate_250ul_Vb_skirted,
  MP_3Pos_Corrected,
)
from pylabrobot.resources.tecan.tecan_decks import EVO150Deck
from pylabrobot.tecan.evo import TecanEVO

deck = EVO150Deck()
evo = TecanEVO(
  name="evo",
  deck=deck,
  diti_count=8,
  air_liha=True,
  has_roma=False,
  packet_read_timeout=30,
  read_timeout=120,
  write_timeout=120,
)

carrier = MP_3Pos_Corrected("carrier")
deck.assign_child_resource(carrier, rails=16)

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
```

If the protocol requires a different layout (e.g. two source plates), modify
positions and tell the user which plate goes where.

---

## Script Template

Every generated script must follow this structure:

```python
"""Protocol: <one-line description>

<Multi-line description of what this protocol does, written for the biologist.>

Deck layout:
  Rail 16, MP_3Pos carrier:
    Position 0: <source plate description>
    Position 1: <dest plate description>
    Position 2: <tip rack description>

Usage:
  python protocol_<name>.py
"""

import asyncio
import logging
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__)))

# --- Deck setup (see Standard Deck Layout above) ---
# ... imports and deck construction ...

logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")

ROWS = ["A", "B", "C", "D", "E", "F", "G", "H"]


async def main():
  print("=" * 60)
  print("  Protocol: <name>")
  print("=" * 60)
  print()
  print("Deck layout:")
  print("  Position 0: <source>")
  print("  Position 1: <dest>")
  print("  Position 2: <tips>")
  print()

  # Confirm with user before starting
  input("Verify deck layout and press Enter to begin...")

  await evo.setup()
  print("EVO initialized.")

  try:
    # === PROTOCOL BODY ===
    # ... tip pickup, aspirate, dispense, tip drop ...
    pass

    print("\n*** PROTOCOL COMPLETE ***")

  except Exception as e:
    print(f"\nProtocol FAILED: {type(e).__name__}: {e}")
    import traceback
    traceback.print_exc()

  finally:
    # Always clean up: raise Z, drop tips if mounted, stop
    try:
      pip_be = evo.pip.backend
      z_range = pip_be._z_range
      num_ch = pip_be.num_channels
      z_params = ",".join([str(z_range)] * num_ch)
      await evo._driver.send_command("C5", command=f"PAZ{z_params}")
    except Exception:
      pass
    await evo.stop()
    print("Done.")


if __name__ == "__main__":
  asyncio.run(main())
```

---

## Core API Reference

### Selecting Wells and Tips

Wells and tips are addressed using Excel-style notation:

```python
# Single well
plate.get_item("A1")

# List of wells (explicit)
plate.get_items(["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"])

# Column shorthand — full column
plate.get_items("A1:H1")   # column 1 (all 8 rows)
plate.get_items("A3:H3")   # column 3

# Row shorthand
plate.get_items("A1:A12")  # row A (all 12 columns)

# Partial selections
plate.get_items("A1:D1")   # top 4 wells of column 1
plate.get_items(["A1", "C1", "E1", "G1"])  # odd rows of column 1
```

### Tip Handling

```python
# Pick up 8 tips from column 1
await evo.pip.pick_up_tips(tip_rack.get_items("A1:H1"))

# Pick up 8 tips from column 3
await evo.pip.pick_up_tips(tip_rack.get_items("A3:H3"))

# Drop tips back to where they came from
await evo.pip.drop_tips(tip_rack.get_items("A1:H1"))

# Pick up fewer than 8 tips (specify channels)
await evo.pip.pick_up_tips(
  tip_rack.get_items(["A1", "B1", "C1", "D1"]),
  use_channels=[0, 1, 2, 3],
)
```

### Aspirate

```python
# Aspirate 25 uL from each well in column 1 (8 channels)
await evo.pip.aspirate(
  source_plate.get_items("A1:H1"),
  vols=[25.0] * 8,
)

# Aspirate different volumes per channel
await evo.pip.aspirate(
  source_plate.get_items("A1:H1"),
  vols=[10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0],
)

# Aspirate with fewer channels
await evo.pip.aspirate(
  source_plate.get_items(["A1", "B1", "C1", "D1"]),
  vols=[25.0] * 4,
  use_channels=[0, 1, 2, 3],
)
```

### Dispense

```python
# Dispense 25 uL to each well in column 2
await evo.pip.dispense(
  dest_plate.get_items("A2:H2"),
  vols=[25.0] * 8,
)

# Dispense different volumes per channel
await evo.pip.dispense(
  dest_plate.get_items("A1:H1"),
  vols=[10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0],
)
```

### Multidrop Combi (Bulk Dispenser)

```python
from pylabrobot.bulk_dispensers import BulkDispenser
from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import MultidropCombiBackend

md = BulkDispenser(backend=MultidropCombiBackend(port="COM3"))
await md.setup()

# Set plate type (0-2 for 96-well, 3-7 for 384-well)
await md.set_plate_type(0)

# Set volume for all columns (uL)
for col in range(1, 13):
  await md.set_column_volume(col, 100.0)

# Dispense
await md.dispense()

# Prime tubing (flush with liquid before first use)
await md.prime(volume=500.0)

# Clean up
await md.stop()
```

---

## Liquid Type Selection

The backend automatically selects liquid handling parameters (speeds, airgaps,
etc.) based on the liquid type. The user does not need to specify these.

Select the appropriate `Liquid` enum based on context:

| User Says | Use |
|-----------|-----|
| water, buffer, PBS, media, saline | `Liquid.WATER` |
| DMSO | `Liquid.DMSO` |
| ethanol, EtOH | `Liquid.ETHANOL` |
| glycerol | `Liquid.GLYCERIN` or `Liquid.GLYCERIN80` |
| methanol, MeOH | `Liquid.METHANOL` |
| serum | `Liquid.SERUM` |
| plasma | `Liquid.PLASMA` |
| acetonitrile, ACN | `Liquid.ACETONITRILE` |
| DNA solution, TE buffer | `Liquid.DNA_TRIS_EDTA` |
| unknown / not specified | `Liquid.WATER` (safest default) |

```python
from pylabrobot.resources.liquid import Liquid
```

If the protocol involves a liquid not in this list, use `Liquid.WATER` and add
a comment noting the approximation.

---

## Safety Rules

The LLM MUST check these rules before generating a script. If a rule would be
violated, warn the user and suggest an alternative.

### Volume Limits
- **50 uL tips**: max aspirate = 50 uL, min = 1 uL
- Never aspirate more than the tip can hold
- Never aspirate more liquid than is in the well
- Account for dead volume: source wells should contain at least 10% more
  than the total volume to be aspirated from them

### Tip Management
- Always pick up tips before aspirating/dispensing
- Always drop tips after finishing with a liquid to avoid cross-contamination
- Change tips between different source liquids unless the user explicitly
  says reuse is acceptable
- The tip rack has 12 columns x 8 rows = 96 tips. Track which columns
  have been used and do not exceed 96 tips per rack
- If the protocol needs more than 96 tips, warn the user that manual tip
  rack replacement will be needed

### Well Volumes
- Eppendorf 96-well 250uL plate: max working volume ~200 uL
- Do not dispense more liquid than a well can hold
- For serial dilutions, calculate cumulative volumes and verify none exceed
  the well capacity

### Channel Count
- The EVO has 8 channels (A-H rows)
- Operations on a single column use all 8 channels
- Operations across columns require separate aspirate/dispense cycles
- For per-well volumes, group operations by column (8 wells per cycle)

### Operation Order
- Tip pickup -> aspirate -> dispense -> tip drop
- Never aspirate without tips mounted
- Never move to a plate position with tips at a Z height that would collide

### Contamination
- Use fresh tips for each new source liquid
- If transferring from source to multiple destinations with the same liquid,
  tips can be reused (aspirate -> dispense -> aspirate -> dispense -> drop)
- For sensitive assays (PCR, cell culture), always use fresh tips

---

## Common Patterns

### Pattern 1: Replicate Column 1 to Columns 2-12

Transfer the contents of column 1 to all other columns. One tip set per
destination column (to avoid carryover).

```python
tip_col = 1
for dest_col in range(2, 13):
  # Pick up fresh tips
  tips = [f"{row}{tip_col}" for row in ROWS]
  await evo.pip.pick_up_tips(tip_rack.get_items(tips))
  tip_col += 1

  # Aspirate from source column 1
  source_wells = [f"{row}1" for row in ROWS]
  await evo.pip.aspirate(source_plate.get_items(source_wells), vols=[25.0] * 8)

  # Dispense to destination column
  dest_wells = [f"{row}{dest_col}" for row in ROWS]
  await evo.pip.dispense(dest_plate.get_items(dest_wells), vols=[25.0] * 8)

  # Drop tips
  await evo.pip.drop_tips(tip_rack.get_items(tips))
```

Note: this uses 11 tip columns (88 tips) for 11 transfers. If the user wants
to reuse tips (same liquid, no contamination concern), pick up once and loop
the aspirate/dispense without dropping.

### Pattern 2: Per-Well Volume Normalization

The user provides a list of volumes per well (e.g. from a quantification assay).
Each well gets a different volume of buffer to normalize concentration.

```python
# User-provided volumes (uL) per well, organized by column
# volumes[col][row] where col=0..11, row=0..7 (A-H)
volumes = [
  [10.0, 15.2, 8.3, 22.1, 5.0, 18.7, 12.4, 9.9],  # column 1
  [20.0, 11.5, 7.8, 25.0, 3.2, 14.1, 19.3, 6.6],  # column 2
  # ... columns 3-12 ...
]

tip_col = 1
for col_idx, col_vols in enumerate(volumes):
  dest_col = col_idx + 1

  # Skip columns where all volumes are 0
  if all(v == 0 for v in col_vols):
    continue

  # Validate volumes
  for i, v in enumerate(col_vols):
    if v > 50.0:
      raise ValueError(f"Volume {v} uL in {ROWS[i]}{dest_col} exceeds 50 uL tip capacity")
    if v < 0:
      raise ValueError(f"Negative volume in {ROWS[i]}{dest_col}")

  # Pick up tips
  tips = [f"{row}{tip_col}" for row in ROWS]
  await evo.pip.pick_up_tips(tip_rack.get_items(tips))
  tip_col += 1

  # Aspirate per-well volumes from buffer reservoir (source plate column 1)
  source_wells = [f"{row}1" for row in ROWS]
  await evo.pip.aspirate(source_plate.get_items(source_wells), vols=col_vols)

  # Dispense to destination
  dest_wells = [f"{row}{dest_col}" for row in ROWS]
  await evo.pip.dispense(dest_plate.get_items(dest_wells), vols=col_vols)

  # Drop tips
  await evo.pip.drop_tips(tip_rack.get_items(tips))
```

### Pattern 3: Serial Dilution

2-fold serial dilution across columns 1-12. Column 1 has the highest
concentration. Each transfer dilutes 1:2 by transferring half the volume
and mixing with diluent already in the wells.

```python
transfer_vol = 25.0  # uL to transfer between columns
# Assumes destination columns 2-12 already have 25 uL diluent

tip_col = 1
for src_col in range(1, 12):
  dest_col = src_col + 1

  tips = [f"{row}{tip_col}" for row in ROWS]
  await evo.pip.pick_up_tips(tip_rack.get_items(tips))
  tip_col += 1

  # Aspirate from current column
  src_wells = [f"{row}{src_col}" for row in ROWS]
  await evo.pip.aspirate(
    dest_plate.get_items(src_wells),
    vols=[transfer_vol] * 8,
  )

  # Dispense to next column
  dst_wells = [f"{row}{dest_col}" for row in ROWS]
  await evo.pip.dispense(
    dest_plate.get_items(dst_wells),
    vols=[transfer_vol] * 8,
  )

  # Drop tips (fresh tips each transfer to avoid carryover)
  await evo.pip.drop_tips(tip_rack.get_items(tips))
```

Note: 11 transfers = 11 tip columns = 88 tips. Fits in one rack.

---

## Generating Good Code

### Do
- Include the full script template with imports, deck setup, and error handling
- Print the deck layout and ask for user confirmation before starting
- Print progress messages (e.g. "Transferring column 3 of 12...")
- Track tip usage and warn if >96 tips needed
- Validate all volumes against tip capacity before starting
- Use descriptive variable names (source_plate, not p1)
- Add comments explaining the protocol logic, not the API calls
- Group operations by column for 8-channel pipetting

### Don't
- Don't generate partial scripts or pseudocode
- Don't hardcode volumes that the user should parameterize
- Don't skip error handling
- Don't assume tip reuse unless explicitly told
- Don't use features not listed in this guide (e.g. mixing, LLD overrides)
  unless you are confident in the API

### Performance Tips
- Minimize tip changes: if the same liquid is being moved to multiple
  destinations, aspirate once and dispense multiple times (if volume allows)
- Process by column (8 wells at a time) rather than by row (1 well at a time)
- For uniform volumes across a whole plate, consider using the Multidrop Combi
  instead of the LiHa (much faster for bulk fills)

---

## Checklist Before Presenting Script

Before showing the generated script to the user, verify:

- [ ] All volumes are within tip capacity (1-50 uL for 50 uL tips)
- [ ] Total tip usage does not exceed 96 (or warn if it does)
- [ ] No well receives more liquid than its capacity (~200 uL)
- [ ] Tips are picked up before every aspirate/dispense cycle
- [ ] Tips are dropped after every cycle (unless explicit reuse)
- [ ] Source wells have enough volume for all aspirations from them
- [ ] The script includes the full template (imports, deck, error handling)
- [ ] The script prints deck layout and waits for user confirmation
- [ ] The script prints progress during execution
- [ ] The finally block raises Z and calls evo.stop()
