Source code for pudu.calibration

from abc import ABC, abstractmethod
from opentrons import protocol_api
from typing import List, Dict, Optional
import xlsxwriter
from pudu.utils import SmartPipette, Camera, colors

[docs] class BaseCalibration(ABC): """ Abstract base class for calibration protocols. Contains shared hardware setup, liquid handling, and serial dilution functionality. Reference: `iGEM 2022 InterLab Calibration Protocol <https://old.igem.org/wiki/images/a/a4/InterLab_2022_-_Calibration_Protocol_v2.pdf>`_ """
[docs] def __init__(self, aspiration_rate: float = 0.5, dispense_rate: float = 1.0, tiprack_labware: str = 'opentrons_96_tiprack_300ul', tiprack_position: str = '9', pipette: str = 'p300_single_gen2', pipette_position: str = 'left', calibration_plate_labware: str = 'corning_96_wellplate_360ul_flat', calibration_plate_position: str = '7', tube_rack_labware: str = 'opentrons_24_aluminumblock_nest_1.5ml_snapcap', tube_rack_position: str = '1', use_falcon_tubes: bool = False, falcon_tube_rack_labware: str = 'opentrons_6_tuberack_falcon_50ml_conical', falcon_tube_rack_position: str = '2', take_picture: bool = False, take_video: bool = False, water_testing: bool = False): """ Initialize shared calibration protocol parameters. Args: aspiration_rate: Aspiration speed as a fraction of the pipette's maximum flow rate (``1.0`` = full speed). Lower values reduce bubble formation with viscous calibrants. dispense_rate: Dispense speed as a fraction of the pipette's maximum flow rate. tiprack_labware: Opentrons labware definition string for the tip rack. tiprack_position: Deck slot string for the tip rack. pipette: Opentrons pipette model string (e.g. ``'p300_single_gen2'``). pipette_position: Mount side for the pipette (``'left'`` or ``'right'``). calibration_plate_labware: Opentrons labware definition string for the 96-well calibration plate where serial dilutions are performed. calibration_plate_position: Deck slot string for the calibration plate. tube_rack_labware: Opentrons labware definition string for the 24-well aluminum block / tube rack holding calibrant stocks and buffers. tube_rack_position: Deck slot string for the tube rack. use_falcon_tubes: If ``True``, PBS and water buffers are sourced from 50 mL Falcon tubes instead of 1.5 mL microtubes. Enables the ``SmartPipette`` conical-tube height calculation for accurate aspiration as the tube empties. falcon_tube_rack_labware: Opentrons labware definition string for the Falcon tube rack. Only used when ``use_falcon_tubes=True``. falcon_tube_rack_position: Deck slot string for the Falcon tube rack. take_picture: If ``True``, capture an image at the start and end of the protocol. take_video: If ``True``, record video for the duration of the protocol. water_testing: If ``True``, skip any steps that require real reagents (reserved for future dry-run support; not fully implemented in all subclasses). """ self.aspiration_rate = aspiration_rate self.dispense_rate = dispense_rate self.tiprack_labware = tiprack_labware self.tiprack_position = tiprack_position self.pipette = pipette self.pipette_position = pipette_position self.calibration_plate_labware = calibration_plate_labware self.calibration_plate_position = calibration_plate_position self.tube_rack_labware = tube_rack_labware self.tube_rack_position = tube_rack_position self.use_falcon_tubes = use_falcon_tubes self.falcon_tube_rack_labware = falcon_tube_rack_labware self.falcon_tube_rack_position = falcon_tube_rack_position self.take_picture = take_picture self.take_video = take_video self.water_testing = water_testing # Shared tracking self.calibrant_positions = {} self.buffer_positions = {} self.camera = Camera() self.smart_pipette = None
@abstractmethod def _get_calibrant_layout(self) -> Dict: """Return the specific calibrant layout for this protocol""" pass @abstractmethod def _load_calibrants(self, protocol, tube_rack) -> None: """Load calibrants specific to this protocol""" pass @abstractmethod def _dispense_initial_calibrants(self, protocol, pipette, plate) -> None: """Dispense initial calibrants to starting positions""" pass @abstractmethod def _perform_serial_dilutions(self, protocol, pipette, plate) -> None: """Perform serial dilutions specific to this protocol""" pass def _define_and_load_liquid(self, protocol, well, name: str, description: str = None, volume: float = 1000, color_index: int = None): """Define liquid and load it into specified well""" if description is None: description = name if color_index is None: color_index = len(self.calibrant_positions) % len(colors) liquid = protocol.define_liquid( name=name, description=description, display_color=colors[color_index] ) well.load_liquid(liquid, volume=volume) protocol.comment(f"Loaded {name} at position {well.well_name}") return well def _setup_hardware(self, protocol): """Setup shared hardware components""" tiprack = protocol.load_labware(self.tiprack_labware, self.tiprack_position) pipette = protocol.load_instrument(self.pipette, self.pipette_position, tip_racks=[tiprack]) self.smart_pipette = SmartPipette(pipette, protocol) plate = protocol.load_labware(self.calibration_plate_labware, self.calibration_plate_position) tube_rack = protocol.load_labware(self.tube_rack_labware, self.tube_rack_position) falcon_tube_rack = None if self.use_falcon_tubes: falcon_tube_rack = protocol.load_labware( self.falcon_tube_rack_labware, self.falcon_tube_rack_position ) return pipette, plate, tube_rack, falcon_tube_rack def _load_dilution_buffers(self, protocol, tube_rack, falcon_tube_rack) -> Dict: """Load PBS and water buffers with falcon tube remapping if needed""" buffers = {} if self.use_falcon_tubes and falcon_tube_rack: # Use falcon tubes with liquid definition pbs_falcon = self._define_and_load_liquid( protocol, falcon_tube_rack['A1'], "PBS Buffer", "Phosphate Buffered Saline for dilutions", volume=15000, color_index=0 ) water_falcon = self._define_and_load_liquid( protocol, falcon_tube_rack['A2'], "Deionized Water", "Deionized Water for dilutions", volume=15000, color_index=1 ) buffers['pbs_sources'] = [pbs_falcon, pbs_falcon] buffers['water_sources'] = [water_falcon, water_falcon] else: # Use individual tubes with liquid definition pbs_1 = self._define_and_load_liquid( protocol, tube_rack['A3'], "PBS Buffer 1", volume=1000, color_index=0 ) pbs_2 = self._define_and_load_liquid( protocol, tube_rack['A4'], "PBS Buffer 2", volume=1000, color_index=0 ) water_1 = self._define_and_load_liquid( protocol, tube_rack['A5'], "Deionized Water 1", volume=1000, color_index=1 ) water_2 = self._define_and_load_liquid( protocol, tube_rack['A6'], "Deionized Water 2", volume=1000, color_index=1 ) buffers['pbs_sources'] = [pbs_1, pbs_2] buffers['water_sources'] = [water_1, water_2] self.buffer_positions = buffers return buffers def _dispense_dilution_buffers(self, protocol, pipette, plate, buffers, wells_layout): """Dispense PBS and water to designated wells""" # Dispense PBS pipette.pick_up_tip() for wells_range, source_idx in wells_layout['pbs']: target_wells = plate.wells()[wells_range[0]:wells_range[1]] source = buffers['pbs_sources'][source_idx] use_conical = self.use_falcon_tubes # Enable conical tube handling for falcon tubes for well in target_wells: self.smart_pipette.liquid_transfer( volume=100, source=source, destination=well, asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, new_tip=False, drop_tip=False, use=use_conical ) pipette.drop_tip() # Dispense water pipette.pick_up_tip() for wells_range, source_idx in wells_layout['water']: target_wells = plate.wells()[wells_range[0]:wells_range[1]] source = buffers['water_sources'][source_idx] use_conical = self.use_falcon_tubes # Enable conical tube handling for falcon tubes for well in target_wells: self.smart_pipette.liquid_transfer( volume=100, source=source, destination=well, asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, new_tip=False, drop_tip=False, use=use_conical ) pipette.drop_tip()
[docs] def run(self, protocol: protocol_api.ProtocolContext): """Main protocol execution using template method pattern""" # Setup hardware pipette, plate, tube_rack, falcon_tube_rack = self._setup_hardware(protocol) # Load calibrants (protocol-specific) self._load_calibrants(protocol, tube_rack) # Load dilution buffers buffers = self._load_dilution_buffers(protocol, tube_rack, falcon_tube_rack) # Get layout for this specific protocol layout = self._get_calibrant_layout() # Media capture start if self.take_picture: self.camera.capture_picture(protocol, when="start") if self.take_video: self.camera.start_video(protocol) # Dispense dilution buffers self._dispense_dilution_buffers(protocol, pipette, plate, buffers, layout) # Dispense initial calibrants (protocol-specific) self._dispense_initial_calibrants(protocol, pipette, plate) # Perform serial dilutions (protocol-specific) self._perform_serial_dilutions(protocol, pipette, plate) # Media capture end if self.take_video: self.camera.stop_video(protocol) if self.take_picture: self.camera.capture_picture(protocol, when="end")
[docs] class GFPODCalibration(BaseCalibration): """ GFP and OD600 calibration using fluorescein and nanoparticles. Based on iGEM 2022 calibration protocol. """ def _get_calibrant_layout(self) -> Dict: """Layout for GFP/OD600 calibration (2 calibrants, 2 replicates each)""" return { 'pbs': [((1, 12), 0), ((13, 24), 1)], # (well_range, source_index) 'water': [((25, 36), 0), ((37, 48), 1)], 'calibrants': { 'fluorescein': ['A1', 'B1'], 'microspheres': ['C1', 'D1'] }, 'dilution_series': [ (0, 11), # Row A fluorescein (12, 23), # Row B fluorescein (24, 35), # Row C microspheres (36, 47) # Row D microspheres ] } def _load_calibrants(self, protocol, tube_rack) -> None: """Load fluorescein and microspheres""" fluorescein_well = self._define_and_load_liquid( protocol, tube_rack['A1'], "Fluorescein 1x", "Fluorescein calibration standard", volume=1000, color_index=2 ) microspheres_well = self._define_and_load_liquid( protocol, tube_rack['A2'], "Microspheres 1x", "Silica nanoparticles for OD600 calibration", volume=1000, color_index=3 ) self.calibrant_positions['fluorescein_1x'] = fluorescein_well self.calibrant_positions['microspheres_1x'] = microspheres_well def _dispense_initial_calibrants(self, protocol, pipette, plate) -> None: """Dispense fluorescein and microspheres to starting wells""" # Fluorescein to A1, B1 for well_name in ['A1', 'B1']: self.smart_pipette.liquid_transfer( volume=200, source=self.calibrant_positions['fluorescein_1x'], destination=plate[well_name], asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, mix_before=200, mix_reps=4 ) # Microspheres to C1, D1 for well_name in ['C1', 'D1']: self.smart_pipette.liquid_transfer( volume=200, source=self.calibrant_positions['microspheres_1x'], destination=plate[well_name], asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, mix_before=200, mix_reps=4 ) def _perform_serial_dilutions(self, protocol, pipette, plate) -> None: """Perform 1:2 serial dilutions for fluorescein and microspheres""" layout = self._get_calibrant_layout() for start_idx, end_idx in layout['dilution_series']: pipette.pick_up_tip() for i in range(start_idx, end_idx): source_well = plate.wells()[i] dest_well = plate.wells()[i + 1] self.smart_pipette.liquid_transfer( volume=100, source=source_well, destination=dest_well, asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, mix_before=200, mix_reps=4, new_tip=False, drop_tip=False ) pipette.drop_tip()
[docs] class RGBODCalibration(BaseCalibration): """ RGB and OD600 calibration using fluorescein, sulforhodamine 101, cascade blue, and nanoparticles. Extended iGEM calibration protocol. """ def _get_calibrant_layout(self) -> Dict: """Layout for RGB/OD600 calibration (4 calibrants, 2 replicates each)""" return { 'pbs': [((1, 12), 0), ((13, 24), 1), ((25, 36), 2), ((37, 48), 3)], 'water': [((49, 60), 0), ((61, 72), 1), ((73, 84), 2), ((85, 96), 3)], 'calibrants': { 'fluorescein': ['A1', 'B1'], 'sulforhodamine': ['C1', 'D1'], 'cascade_blue': ['E1', 'F1'], 'microspheres': ['G1', 'H1'] }, 'dilution_series': [ (0, 10), # Row A fluorescein (to well 11, discard to binit) (12, 22), # Row B fluorescein (24, 34), # Row C sulforhodamine (36, 46), # Row D sulforhodamine (48, 58), # Row E cascade blue (60, 70), # Row F cascade blue (72, 82), # Row G microspheres (to well 83, discard to binit) (84, 94) # Row H microspheres ] } def _load_calibrants(self, protocol, tube_rack) -> None: """Load all RGB calibrants and microspheres""" sulforhodamine_well = self._define_and_load_liquid( protocol, tube_rack['A1'], "Sulforhodamine 101 1x", "Sulforhodamine 101 red fluorescent calibrant", volume=1000, color_index=2 ) fluorescein_well = self._define_and_load_liquid( protocol, tube_rack['B1'], "Fluorescein 1x", "Fluorescein green fluorescent calibrant", volume=1000, color_index=3 ) cascade_blue_well = self._define_and_load_liquid( protocol, tube_rack['C1'], "Cascade Blue 1x", "Cascade Blue blue fluorescent calibrant", volume=1000, color_index=4 ) microspheres_well = self._define_and_load_liquid( protocol, tube_rack['D1'], "Microspheres 1x", "Silica nanoparticles for OD600 calibration", volume=1000, color_index=5 ) binit_well = self._define_and_load_liquid( protocol, tube_rack['A6'], "Waste Container", "Container for waste disposal", volume=0, color_index=6 ) self.calibrant_positions.update({ 'sulforhodamine_1x': sulforhodamine_well, 'fluorescein_1x': fluorescein_well, 'cascade_blue_1x': cascade_blue_well, 'microspheres_1x': microspheres_well, 'binit': binit_well }) def _load_dilution_buffers(self, protocol, tube_rack, falcon_tube_rack) -> Dict: """Extended buffer loading for RGB protocol""" buffers = {} if self.use_falcon_tubes and falcon_tube_rack: # Use falcon tubes for all buffers pbs_falcon = falcon_tube_rack['A1'] water_falcon = falcon_tube_rack['A2'] buffers['pbs_sources'] = [pbs_falcon] * 8 buffers['water_sources'] = [water_falcon] * 8 else: # Use individual tubes buffers['pbs_sources'] = [ tube_rack['A2'], tube_rack['B2'], tube_rack['C2'], tube_rack['D2'], tube_rack['A3'], tube_rack['B3'], tube_rack['C3'], tube_rack['D3'] ] buffers['water_sources'] = [ tube_rack['A4'], tube_rack['B4'], tube_rack['C4'], tube_rack['D4'], tube_rack['A5'], tube_rack['B5'], tube_rack['C5'], tube_rack['D5'] ] self.buffer_positions = buffers return buffers def _dispense_initial_calibrants(self, protocol, pipette, plate) -> None: """Dispense all RGB calibrants to starting wells""" mix_vol = 100 mix_reps = 3 calibrants_wells = [ ('fluorescein_1x', ['A1', 'B1']), ('sulforhodamine_1x', ['C1', 'D1']), ('cascade_blue_1x', ['E1', 'F1']), ('microspheres_1x', ['G1', 'H1']) ] for calibrant, well_names in calibrants_wells: for well_name in well_names: self.smart_pipette.liquid_transfer( volume=200, source=self.calibrant_positions[calibrant], destination=plate[well_name], asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, mix_before=mix_vol, mix_reps=mix_reps ) def _perform_serial_dilutions(self, protocol, pipette, plate) -> None: """Perform 1:2 serial dilutions with waste disposal""" layout = self._get_calibrant_layout() mix_vol = 100 mix_reps = 3 binit = self.calibrant_positions['binit'] for start_idx, end_idx in layout['dilution_series']: pipette.pick_up_tip() # Serial dilutions for i in range(start_idx, end_idx): source_well = plate.wells()[i] dest_well = plate.wells()[i + 1] self.smart_pipette.liquid_transfer( volume=100, source=source_well, destination=dest_well, asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, mix_before=mix_vol, mix_reps=mix_reps, new_tip=False, drop_tip=False ) # Discard final dilution to binit final_well = plate.wells()[end_idx] self.smart_pipette.liquid_transfer( volume=100, source=final_well, destination=binit, asp_rate=self.aspiration_rate, disp_rate=self.dispense_rate, mix_before=mix_vol, mix_reps=mix_reps, new_tip=False, drop_tip=False ) pipette.drop_tip() # Fill all wells to 200 µL with appropriate buffers self._fill_wells_to_200ul(protocol, pipette, plate) def _fill_wells_to_200ul(self, protocol, pipette, plate) -> None: """Add additional 100 µL to all wells to reach 200 µL final volume""" layout = self._get_calibrant_layout() buffers = self.buffer_positions use_conical = self.use_falcon_tubes # Enable conical tube handling for falcon tubes # Add PBS to calibrant wells pipette.pick_up_tip() for wells_range, source_idx in layout['pbs']: target_wells = plate.wells()[wells_range[0]:wells_range[1]] source = buffers['pbs_sources'][source_idx] for well in target_wells: self.smart_pipette.liquid_transfer( protocol, pipette, 100, source, well, new_tip=False, drop_tip=False, use=use_conical ) pipette.drop_tip() # Add water to blank wells pipette.pick_up_tip() for wells_range, source_idx in layout['water']: target_wells = plate.wells()[wells_range[0]:wells_range[1]] source = buffers['water_sources'][source_idx] for well in target_wells: self.smart_pipette.liquid_transfer( protocol, pipette, 100, source, well, new_tip=False, drop_tip=False, use=use_conical ) pipette.drop_tip()