Week 3 HW: Lab Automation

Part 1: Design some Art

This is I think not my final art - i would like to change it for the final submission but i wanted to check out how to write python first and if i manage to implement it.

cover image cover image

https://opentrons-art.rcdonovan.com/?id=uauqdo374y5jjq1

Part 2: Writing the python code

I am not experienced with writing a python code so i had to try around quite a bit but I think I managed - at least the simulation tool gave me some results! :D

https://colab.research.google.com/drive/1I2iWDn4lVa5APjPBOefBhoj4b07UhK3X#scrollTo=pczDLwsq64mk&line=2&uniqifier=1

import subprocess, sys
subprocess.check_call([sys.executable, "-m", "pip", "install", "numpy", "pandas"])
import numpy as np
import pandas as pd

from opentrons import types

metadata = {    # see https://docs.opentrons.com/v2/tutorial.html#tutorial-metadata
    'author': 'Dominik Einfalt',
    'protocolName': 'HTGAA Opentrons Lab',
    'description': 'PetriPlant - Image on petri dish',
    'source': 'HTGAA 2026 Opentrons Lab',
    'apiLevel': '2.20'
}

##############################################################################
###   Robot deck setup constants - don't change these
##############################################################################

TIP_RACK_DECK_SLOT = 9
COLORS_DECK_SLOT = 6
AGAR_DECK_SLOT = 5
PIPETTE_STARTING_TIP_WELL = 'B1'

well_colors = {
    'A1' : 'Red',
    'B1' : 'Green',
    'C1' : 'Orange'
}


def run(protocol):
  ##############################################################################
  ###   Load labware, modules and pipettes
  ##############################################################################

  # Tips
  tips_20ul = protocol.load_labware('opentrons_96_tiprack_20ul', TIP_RACK_DECK_SLOT, 'Opentrons 20uL Tips')

  # Pipettes
  pipette_20ul = protocol.load_instrument("p20_single_gen2", "right", [tips_20ul])

  # Modules
  temperature_module = protocol.load_module('temperature module gen2', COLORS_DECK_SLOT)

  # Temperature Module Plate
  temperature_plate = temperature_module.load_labware('opentrons_96_aluminumblock_generic_pcr_strip_200ul',
                                                      'Cold Plate')
  # Choose where to take the colors from
  color_plate = temperature_plate

  # Agar Plate
  agar_plate = protocol.load_labware('htgaa_agar_plate', AGAR_DECK_SLOT, 'Agar Plate')  ## TA MUST CALIBRATE EACH PLATE!
  # Get the top-center of the plate, make sure the plate was calibrated before running this
  center_location = agar_plate['A1'].top()

  pipette_20ul.starting_tip = tips_20ul.well(PIPETTE_STARTING_TIP_WELL)

  ##############################################################################
  ###   Patterning
  ##############################################################################

  ###
  ### Helper functions for this lab
  ###

  # pass this e.g. 'Red' and get back a Location which can be passed to aspirate()
  def location_of_color(color_string):
    for well,color in well_colors.items():
      if color.lower() == color_string.lower():
        return color_plate[well]
    raise ValueError(f"No well found with color {color_string}")

  # For this lab, instead of calling pipette.dispense(1, loc) use this: dispense_and_detach(pipette, 1, loc)
  def dispense_and_detach(pipette, volume, location):
      """
      Move laterally 5mm above the plate (to avoid smearing a drop); then drop down to the plate,
      dispense, move back up 5mm to detach drop, and stay high to be ready for next lateral move.
      5mm because a 4uL drop is 2mm diameter; and a 2deg tilt in the agar pour is >3mm difference across a plate.
      """
      assert(isinstance(volume, (int, float)))
      above_location = location.move(types.Point(z=location.point.z + 5))  # 5mm above
      pipette.move_to(above_location)       # Go to 5mm above the dispensing location
      pipette.dispense(volume, location)    # Go straight downwards and dispense
      pipette.move_to(above_location)       # Go straight up to detach drop and stay high

  ###
  ### YOUR CODE HERE to create your design
  ###

   #Coordinates of my Design
  url = 'https://edit.htgaa.org/2026a-dominik-einfalt/webpages/raw/branch/main/content/homework/IMG/points_Petriplant.csv'
  world_coord = pd.read_csv(url)
  data = world_coord
  data.columns = ["x", "y"]


  x_value = data['x']
  y_value = data['y']

  #Change to location of green
  cell_well = color_plate['B1']


  # Aspirate
  pipette_20ul.pick_up_tip()

  for i in range(len(x_value)):
    if i%20 == 0:
      # pick up more every 20 uL, but only as much as we're going to need!
      pipette_20ul.aspirate(min(20, len(x_value)-i), cell_well)

    adjusted_location = center_location.move(types.Point(x_value[i], y_value[i]))
    pipette_20ul.dispense(1, adjusted_location)
    hover_location = adjusted_location.move(types.Point(z = 2))
    pipette_20ul.move_to(hover_location)

  pipette_20ul.drop_tip()

  # Don't forget to end with a drop_tip()
cover image cover image

https://colab.research.google.com/drive/1ah0k5Q00aQBtg8QPGjdbyycOvA5HUMBm#scrollTo=pczDLwsq4mk&line=158&uniqifier=1

cover image cover image
from opentrons import types
import math

metadata = {    # see https://docs.opentrons.com/v2/tutorial.html#tutorial-metadata
    'author': '',
    'protocolName': '',
    'description': '',
    'source': 'HTGAA 2026 Opentrons Lab',
    'apiLevel': '2.20'
}

##############################################################################
###   Robot deck setup constants - don't change these
##############################################################################

TIP_RACK_DECK_SLOT = 9
COLORS_DECK_SLOT = 6
AGAR_DECK_SLOT = 5
PIPETTE_STARTING_TIP_WELL = 'A1'

well_colors = {
    'A1' : 'Red',
    'B1' : 'Green',
    'C1' : 'Orange'
}


def run(protocol):
  ##############################################################################
  ###   Load labware, modules and pipettes
  ##############################################################################

  # Tips
  tips_20ul = protocol.load_labware('opentrons_96_tiprack_20ul', TIP_RACK_DECK_SLOT, 'Opentrons 20uL Tips')

  # Pipettes
  pipette_20ul = protocol.load_instrument("p20_single_gen2", "right", [tips_20ul])

  # Modules
  temperature_module = protocol.load_module('temperature module gen2', COLORS_DECK_SLOT)

  # Temperature Module Plate
  temperature_plate = temperature_module.load_labware('opentrons_96_aluminumblock_generic_pcr_strip_200ul',
                                                      'Cold Plate')
  # Choose where to take the colors from
  color_plate = temperature_plate

  # Agar Plate
  agar_plate = protocol.load_labware('htgaa_agar_plate', AGAR_DECK_SLOT, 'Agar Plate')  ## TA MUST CALIBRATE EACH PLATE!
  # Get the top-center of the plate, make sure the plate was calibrated before running this
  center_location = agar_plate['A1'].top()

  pipette_20ul.starting_tip = tips_20ul.well(PIPETTE_STARTING_TIP_WELL)

  ##############################################################################
  ###   Patterning
  ##############################################################################

  ###
  ### Helper functions for this lab
  ###

  # pass this e.g. 'Red' and get back a Location which can be passed to aspirate()
  def location_of_color(color_string):
    for well,color in well_colors.items():
      if color.lower() == color_string.lower():
        return color_plate[well]
    raise ValueError(f"No well found with color {color_string}")

  # For this lab, instead of calling pipette.dispense(1, loc) use this: dispense_and_detach(pipette, 1, loc)
  def dispense_and_detach(pipette, volume, location):
      """
      Move laterally 5mm above the plate (to avoid smearing a drop); then drop down to the plate,
      dispense, move back up 5mm to detach drop, and stay high to be ready for next lateral move.
      5mm because a 4uL drop is 2mm diameter; and a 2deg tilt in the agar pour is >3mm difference across a plate.
      """
      assert(isinstance(volume, (int, float)))
      above_location = location.move(types.Point(z=location.point.z + 5))  # 5mm above
      pipette.move_to(above_location)       # Go to 5mm above the dispensing location
      pipette.dispense(volume, location)    # Go straight downwards and dispense
      pipette.move_to(above_location)       # Go straight up to detach drop and stay high

  ###
  ### YOUR CODE HERE to create your design
  ###


  startY = 34
  # halfStartY = int(startY/2)
  halfStartY = 17

  cursor = center_location.move(types.Point(x=-halfStartY, y = startY))

  triangle_radius = 15  # mm from center to each corner

# Equilateral triangle centered at (0,0)
  corners = [
    (0, triangle_radius),  # top corner
    (-triangle_radius * math.sin(math.pi/3),
     -triangle_radius / 2),  # bottom left
    (triangle_radius * math.sin(math.pi/3),
     -triangle_radius / 2)   # bottom right
    ]

  rows = 10
  spacing = 3.5

  points = []

  # Calculate offset to center the y-coordinates of the generated points
  total_height = (rows - 1) * spacing
  y_offset = total_height / 2

  for row in range(rows):
    for col in range(row + 1):
        x = (col - row/2) * spacing
        y = row * spacing - y_offset  # Adjust y to center the triangle vertically

        points.append((x, y))

  def barycentric_weights(px, py, c1, c2, c3):
    x1,y1 = c1
    x2,y2 = c2
    x3,y3 = c3

    det = (y2 - y3)*(x1 - x3) + (x3 - x2)*(y1 - y3)

    w1 = ((y2 - y3)*(px - x3) + (x3 - x2)*(py - y3)) / det
    w2 = ((y3 - y1)*(px - x3) + (x1 - x3)*(py - y3)) / det
    w3 = 1 - w1 - w2

    return w1, w2, w3

  total_volume = 3  # µL per dot

  red_well = color_plate['A1']
  green_well = color_plate['B1']
  orange_well = color_plate['C1']

  # Max volume the pipette can hold in one go
  pipette_max_vol = pipette_20ul.max_volume # This is 20uL for p20_single_gen2

  # Helper function to perform aspirate/multi-dispense/refill
  def perform_multi_dispense_for_color(pipette, color_source_well, all_dispense_tasks):
    if not all_dispense_tasks:
        return # Nothing to dispense

    pipette.pick_up_tip()
    current_volume_in_pipette = 0

    for i, (vol_to_dispense, loc_to_dispense) in enumerate(all_dispense_tasks):
      if current_volume_in_pipette < vol_to_dispense:
        # Calculate how much volume is still needed for the *remaining* dispenses
        remaining_needed = sum(task[0] for task in all_dispense_tasks[i:])

        # Calculate space available in pipette
        space_in_pipette = pipette_max_vol - current_volume_in_pipette

        # Aspirate the minimum of space available, and what is actually needed for remaining tasks
        amount_to_aspirate = min(space_in_pipette, remaining_needed)

        # Make sure we don't try to aspirate 0 or negative volume
        if amount_to_aspirate > 0:
          pipette.aspirate(amount_to_aspirate, color_source_well)
          current_volume_in_pipette += amount_to_aspirate

      dispense_and_detach(pipette, vol_to_dispense, loc_to_dispense)
      current_volume_in_pipette -= vol_to_dispense

    pipette.drop_tip()


  # =========================
  # RED PASS
  # =========================
  red_dispense_tasks = []
  for (x, y) in points:
    w1, w2, w3 = barycentric_weights(x, y, corners[0], corners[1], corners[2])
    red_vol = total_volume * w1
    if red_vol > 0:
      dispense_location = center_location.move(
          types.Point(x=x, y=y)
      )
      red_dispense_tasks.append((red_vol, dispense_location))

  perform_multi_dispense_for_color(pipette_20ul, red_well, red_dispense_tasks)


  # =========================
  # GREEN PASS
  # =========================
  green_dispense_tasks = []
  for (x, y) in points:
    w1, w2, w3 = barycentric_weights(x, y, corners[0], corners[1], corners[2])
    green_vol = total_volume * w2
    if green_vol > 0:
      dispense_location = center_location.move(
          types.Point(x=x, y=y)
      )
      green_dispense_tasks.append((green_vol, dispense_location))

  perform_multi_dispense_for_color(pipette_20ul, green_well, green_dispense_tasks)


  # =========================
  # ORANGE PASS
  # =========================
  orange_dispense_tasks = []
  for (x, y) in points:
    w1, w2, w3 = barycentric_weights(x, y, corners[0], corners[1], corners[2])
    orange_vol = total_volume * w3
    if orange_vol > 0:
      dispense_location = center_location.move(
          types.Point(x=x, y=y)
      )
      orange_dispense_tasks.append((orange_vol, dispense_location))

  perform_multi_dispense_for_color(pipette_20ul, orange_well, orange_dispense_tasks)

https://opentrons-art.rcdonovan.com/?id=uauqdo374y5jjq1

cover image cover imagecover image cover imagecover image cover image

Find and describe

AssemblyTron: flexible automation of DNA assembly with Opentrons OT-2 lab robots

The paper presents AssemblyTron, an open‑source software that automates DNA assembly on the Opentrons OT‑2 robot. It translates design outputs from tools like j5 into pipetting protocols, supporting PCR setup, Golden Gate assembly, and other DNA assembly methods. It showed high accuracy, reproduciblity and is comparable to manual assembly, but much faster and easier. This work demonstrates a low-cost, accessible approach to automating complex synthetic biology workflows, reducing human error and increasing throughput.

Write a description

Automated Dose–Response Mapping of Pollutant Detection

My project could use the Opentrons OT-2 to automate the dose–response analysis of heavy metal pollutants. The robot could perform automated serial dilutions to establish a concentration gradient of metals (e.g., lead, copper, or mercury) across a 96-well plate. It then handles the standardized inoculation of engineered bacteria, which are designed to produce an odor in response to specific contaminants. This automated setup allows for the rapid generation of quantitative dose–response maps, precisely linking pollutant levels to bacterial output while minimizing human error and increasing throughput.