diff --git a/robotools/evotools/__init__.py b/robotools/evotools/__init__.py index 6c6a9fa..df65075 100644 --- a/robotools/evotools/__init__.py +++ b/robotools/evotools/__init__.py @@ -9,7 +9,7 @@ import numpy -from .. import liquidhandling +from .. import liquidhandling, transform logger = logging.getLogger("evotools") @@ -36,6 +36,30 @@ class InvalidOperationError(Exception): pass +def _int_to_tip(tip_int: int): + """Asserts a Tecan Tip class to an int between 1 and 8.""" + if not 1 <= tip_int <= 8: + raise ValueError( + f"Tip is {tip_int} with type {type(tip_int)}, but should be an int between 1 and 8 for _int_to_tip conversion." + ) + if tip_int == 1: + return Tip.T1 + elif tip_int == 2: + return Tip.T2 + elif tip_int == 3: + return Tip.T3 + elif tip_int == 4: + return Tip.T4 + elif tip_int == 5: + return Tip.T5 + elif tip_int == 6: + return Tip.T6 + elif tip_int == 7: + return Tip.T7 + elif tip_int == 8: + return Tip.T8 + + def _prepare_aspirate_dispense_parameters( rack_label: str, position: int, @@ -122,29 +146,6 @@ def _prepare_aspirate_dispense_parameters( if not isinstance(liquid_class, str) or ";" in liquid_class: raise ValueError(f"Invalid liquid_class: {liquid_class}") - def _int_to_tip(tip_int: int): - """Asserts a Tecan Tip class to an int between 1 and 8.""" - if not 1 <= tip_int <= 8: - raise ValueError( - f"Tip is {tip} with type {type(tip)}, but should be an int between 1 and 8 for _int_to_tip conversion." - ) - if tip_int == 1: - return Tip.T1 - elif tip_int == 2: - return Tip.T2 - elif tip_int == 3: - return Tip.T3 - elif tip_int == 4: - return Tip.T4 - elif tip_int == 5: - return Tip.T5 - elif tip_int == 6: - return Tip.T6 - elif tip_int == 7: - return Tip.T7 - elif tip_int == 8: - return Tip.T8 - if isinstance(tip, int) and not isinstance(tip, Tip): # User-specified integers from 1-8 need to be converted to Tecan logic tip = _int_to_tip(tip) @@ -181,6 +182,297 @@ def _int_to_tip(tip_int: int): return rack_label, position, volume, liquid_class, tip, rack_id, tube_id, rack_type, forced_rack_type +def _prepare_evo_aspirate_dispense_parameters( + labware: liquidhandling.Labware, + wells: typing.Union[str, typing.Sequence[str], numpy.ndarray], + *, + labware_position: typing.Tuple[int, int], + volume: typing.Union[float, typing.List[float], int], + liquid_class: str, + tips: typing.Union[typing.List[Tip], typing.List[int]], + max_volume: typing.Optional[int] = None, +) -> typing.Tuple[str, list, tuple, float, str, list]: + """Validates and prepares aspirate/dispense parameters. + + Parameters + ---------- + labware : liquidhandling.Labware + Source labware + wells : list of str + List with target well ID(s) + labware_position : tuple + Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) + volume : int, float or list + Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases + liquid_class : str, optional + Overwrites the liquid class for this step (max 32 characters) + tips : list of int + Tip(s) that will be selected (out of tips 1-8) + max_volume : int, optional + Maximum allowed volume + + Returns + ------- + labware : liquidhandling.Labware + Source labware + wells : list of str + List with target well ID(s) + labware_position : tuple + Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) + volume : list + Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases + liquid_class : str, optional + Overwrites the liquid class for this step (max 32 characters) + tips : list of int + Tip(s) that will be selected (out of tips 1-8) + """ + if labware is None: + raise ValueError("Missing required parameter: labware") + if not isinstance(labware, liquidhandling.Labware): + raise ValueError(f"Invalid labware: {labware}") + + if wells is None: + raise ValueError("Missing required parameter: wells") + if not isinstance(wells, (str, list, tuple, numpy.ndarray)): + raise ValueError(f"Invalid wells: {wells}") + if not len(wells) == len(tips): + raise ValueError(f"Invalid wells: wells and tips need to have the same length.") + if labware_position is None: + raise ValueError("Missing required parameter: position") + if not all(isinstance(position, int) for position in labware_position) or any( + position < 0 for position in labware_position + ): + raise ValueError(f"Invalid position: {labware_position}") + + if volume is None: + raise ValueError("Missing required parameter: volume") + if isinstance(volume, list): + for vol in volume: + try: + vol = float(vol) + except: + raise ValueError(f"Invalid volume: {vol}") + if vol < 0 or vol > 7158278 or numpy.isnan(vol): + raise ValueError(f"Invalid volume: {vol}") + if max_volume is not None and vol > max_volume: + raise InvalidOperationError(f"Invalid volume: volume of {vol} exceeds max_volume.") + if not len(volume) == len(tips) == len(wells): + raise Exception( + f"Invalid volume: Tips, wells, and volume lists have different lengths ({len(tips)}, {len(wells)} and {len(volume)}, respectively)." + ) + elif isinstance(volume, (float, int)): + # test volume like in the list section + if volume < 0 or volume > 7158278 or numpy.isnan(volume): + raise ValueError(f"Invalid volume: {volume}") + if max_volume is not None and volume > max_volume: + raise InvalidOperationError(f"Invalid volume: volume of {volume} exceeds max_volume.") + # convert volume to list and multiply list to reach identical length as wells + volume = [float(volume)] * len(wells) + else: + raise ValueError(f"Invalid volume: {volume}") + + # apply rounding and corrections for the right string formatting + volume = numpy.round(volume, decimals=2).tolist() + + if liquid_class is None: + raise ValueError(f"Missing required parameter: liquid_class") + if not isinstance(liquid_class, str) or ";" in liquid_class: + raise ValueError(f"Invalid liquid_class: {liquid_class}") + + if tips is None: + raise ValueError(f"Missing required parameter: tips") + for tip in tips: + if not isinstance(tip, (int, Tip)): + raise ValueError(f"Invalid type of tips: {type(tip)}. Has to be int or Tip.") + tecan_tips = [] + for tip in tips: + if isinstance(tip, int) and not isinstance(tip, Tip): + # User-specified integers from 1-8 need to be converted to Tecan logic + tip = _int_to_tip(tip) + tecan_tips.append(tip) + + return labware, wells, labware_position, volume, liquid_class, tecan_tips + + +def _prepare_evo_wash_parameters( + *, + tips: typing.Union[typing.List[Tip], typing.List[int]], + waste_location: typing.Tuple[int, int], + cleaner_location: typing.Tuple[int, int], + arm: int = 0, + waste_vol: float = 3.0, + waste_delay: int = 500, + cleaner_vol: float = 4.0, + cleaner_delay: int = 500, + airgap: int = 10, + airgap_speed: int = 70, + retract_speed: int = 30, + fastwash: int = 1, + low_volume: int = 0, +) -> typing.Tuple[list, tuple, tuple, int, float, int, float, int, int, int, int, int, int]: + """Validates and prepares aspirate/dispense parameters. + + Parameters + ---------- + tips : list + Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 + waste_location : tuple + Tuple with grid position (1-67) and site number (0-127) of waste as integers + cleaner_location : tuple + Tuple with grid position (1-67) and site number (0-127) of cleaner as integers + arm : int + number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2 + waste_vol: float + Volume in waste in mL (0-100) + waste_delay : int + Delay before closing valves in waste in ms (0-1000) + cleaner_vol: float + Volume in cleaner in mL (0-100) + cleaner_delay : int + Delay before closing valves in cleaner in ms (0-1000) + airgap : int + Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100) + airgap_speed : int + Speed of airgap aspiration in µL/s (1-1000) + retract_speed : int + Retract speed in mm/s (1-100) + fastwash : int + Use fast-wash module = 1, don't use it = 0 + low_volume : int + Use pinch valves = 1, don't use them = 0 + + Returns + ------- + tips : list + Tip(s) that will be selected; have been converted to tip.T1 - tip.T8 here if they weren't originally formatted that way + waste_location : tuple + Tuple with grid position (1-67) and site number (0-127) of waste as integers + cleaner_location : tuple + Tuple with grid position (1-67) and site number (0-127) of cleaner as integers + arm : int + number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2 + waste_vol: float + Volume in waste in mL (0-100) + waste_delay : int + Delay before closing valves in waste in ms (0-1000) + cleaner_vol: float + Volume in cleaner in mL (0-100) + cleaner_delay : int + Delay before closing valves in cleaner in ms (0-1000) + airgap : int + Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100) + airgap_speed : int + Speed of airgap aspiration in µL/s (1-1000) + retract_speed : int + Retract speed in mm/s (1-100) + fastwash : int + Use fast-wash module = 1, don't use it = 0 + low_volume : int + Use pinch valves = 1, don't use them = 0 + """ + if tips is None: + raise ValueError("Missing required parameter: tips") + + tecan_tips = [] + for tip in tips: + if isinstance(tip, int) and not isinstance(tip, Tip): + # User-specified integers from 1-8 need to be converted to Tecan logic + tip = _int_to_tip(tip) + tecan_tips.append(tip) + + if waste_location is None: + raise ValueError("Missing required parameter: waste_location") + grid, site = waste_location + if not isinstance(grid, int) or not 1 <= grid <= 67: + raise ValueError("Grid (first number in waste_location tuple) has to be an int from 1 - 67.") + if not isinstance(site, int) or not 0 <= site <= 127: + raise ValueError("Site (second number in waste_location tuple) has to be an int from 0 - 127.") + + if cleaner_location is None: + raise ValueError("Missing required parameter: cleaner_location") + grid, site = cleaner_location + if not isinstance(grid, int) or not 1 <= grid <= 67: + raise ValueError("Grid (first number in cleaner_location tuple) has to be an int from 1 - 67.") + if not isinstance(site, int) or not 0 <= site <= 127: + raise ValueError("Site (second number in cleaner_location tuple) has to be an int from 0 - 127.") + + if arm is None: + raise ValueError("Missing required paramter: arm") + if not isinstance(arm, int): + raise ValueError("Parameter arm is not int.") + if not arm == 0 and not arm == 1: + raise ValueError("Parameter arm has to be 0 (LiHa 1) or 1 (LiHa 2).") + + if waste_vol is None: + raise ValueError("Missing required parameter: waste_vol") + if not isinstance(waste_vol, float) or not 0 <= waste_vol <= 100: + raise ValueError("waste_vol has to be a float from 0 - 100.") + # round waste_vol to the first decimal (pre-requisite for Tecan's wash command) + waste_vol = numpy.round(waste_vol, 1) + + if waste_delay is None: + raise ValueError("Missing required parameter: waste_delay") + if not isinstance(waste_delay, int) or not 0 <= waste_delay <= 1000: + raise ValueError("waste_delay has to be an int from 0 - 1000.") + + if cleaner_vol is None: + raise ValueError("Missing required parameter: cleaner_vol") + if not isinstance(cleaner_vol, float) or not 0 <= cleaner_vol <= 100: + raise ValueError("cleaner_vol has to be a float from 0 - 100.") + # round cleaner_vol to the first decimal (pre-requisite for Tecan's wash command) + cleaner_vol = numpy.round(cleaner_vol, 1) + + if cleaner_delay is None: + raise ValueError("Missing required parameter: cleaner_delay") + if not isinstance(cleaner_delay, int) or not 0 <= cleaner_delay <= 1000: + raise ValueError("cleaner_delay has to be an int from 0 - 1000.") + + if airgap is None: + raise ValueError("Missing required parameter: airgap") + if not isinstance(airgap, int) or not 0 <= airgap <= 100: + raise ValueError("airgap has to be an int from 0 - 100.") + + if airgap_speed is None: + raise ValueError("Missing required parameter: airgap_speed") + if not isinstance(airgap_speed, int) or not 1 <= airgap_speed <= 1000: + raise ValueError("airgap_speed has to be an int from 1 - 1000.") + + if retract_speed is None: + raise ValueError("Missing required parameter: retract_speed") + if not isinstance(retract_speed, int) or not 1 <= retract_speed <= 100: + raise ValueError("retract_speed has to be an int from 1 - 100.") + + if fastwash is None: + raise ValueError("Missing required paramter: fastwash") + if not isinstance(fastwash, int): + raise ValueError("Parameter fastwash is not int.") + if not fastwash == 0 and not fastwash == 1: + raise ValueError("Parameter fastwash has to be 0 (no fast-wash) or 1 (use fast-wash).") + + if low_volume is None: + raise ValueError("Missing required paramter: low_volume") + if not isinstance(low_volume, int): + raise ValueError("Parameter low_volume is not int.") + if not low_volume == 0 and not low_volume == 1: + raise ValueError("Parameter low_volume has to be 0 (no fast-wash) or 1 (use fast-wash).") + + return ( + tecan_tips, + waste_location, + cleaner_location, + arm, + waste_vol, + waste_delay, + cleaner_vol, + cleaner_delay, + airgap, + airgap_speed, + retract_speed, + fastwash, + low_volume, + ) + + def _optimize_partition_by( source: liquidhandling.Labware, destination: liquidhandling.Labware, @@ -309,6 +601,93 @@ def _partition_by_column( return column_groups +def to_hex(dec: int): + """Method from stackoverflow to convert decimal to hex. + Link: https://stackoverflow.com/questions/5796238/python-convert-decimal-to-hex + Solution posted by user "Chunghee Kim" on 21.11.2020. + """ + digits = "0123456789ABCDEF" + x = dec % 16 + rest = dec // 16 + if rest == 0: + return digits[x] + return to_hex(rest) + digits[x] + + +def evo_make_selection_array(rows: int, columns: int, wells: numpy.ndarray): + """Translate well IDs to a numpy array with 1s (selected) and 0s (not selected). + + Parameters + ---------- + rows : int + Number of rows of target labware object + cols : int + Number of columns of target labware object + wells : List[str] + Selected wells by well IDs as strings (e.g. ["A01", "B01"]) + + Returns + ------- + selection_array : numpy.ndarray + Numpy array in labware dimensions with selected wells as 1 and others as 0 + """ + # create array with a shape beffiting the labware dimensions + selection_array = numpy.zeros((rows, columns)) + # get a dictionary with the "coordinates" of well IDs (A01, B01 etc) as tuples + well_index_dict = transform.make_well_index_dict(rows, columns) + # insert 1s for all selected wells + for well in wells: + selection_array[well_index_dict[well]] = 1 + return selection_array + + +def evo_get_selection(rows: int, cols: int, selected: numpy.ndarray): + """Function to generate the code string for the well selection of pipetting actions in EvoWare scripts (.esc). + Adapted from the C++ function detailed in the EvoWare manual to Python by Martin Beyß (except the test at the end). + + Parameters + ---------- + rows : int + Number of rows of target labware object + cols : int + Number of columns of target labware object + selected : numpy.ndarray + Numpy array in labware dimensions with selected wells as 1 and others as 0 (from evo_make_selection_array) + + Returns + ------- + selection : str + Code string for well selection of pipetting actions in EvoWare scripts (.esc) + """ + # apply bit mask with 7 bits, adapted from function detailed in EvoWare manual + selection = f"0{to_hex(cols)}{rows:02d}" + bit_counter = 0 + bit_mask = 0 + for x in range(cols): + for y in range(rows): + if selected[y, x] == 1: + bit_mask |= 1 << bit_counter + bit_counter += 1 + if bit_counter > 6: + selection += chr(bit_mask + 48) + bit_counter = 0 + bit_mask = 0 + if bit_counter > 0: + selection += chr(bit_mask + 48) + + # check if wells from more than one column are selected and raise Exception if so + check = 0 + for column in selected.transpose(): + if sum(column) >= 1: + check += 1 + if check >= 2: + raise Exception( + "Wells from more than one column are selected.\nSelect only wells from one column per pipetting action." + ) + + return selection + + class Worklist(list): """Context manager for the creation of Worklists.""" @@ -352,7 +731,7 @@ def save(self, filepath: str) -> None: assert ".gwl" in filepath.lower(), "The filename did not contain the .gwl extension." if os.path.exists(filepath): os.remove(filepath) - with open(filepath, "w", newline="\r\n", encoding="latin-1") as file: + with open(filepath, "w", newline="\r\n", encoding="latin_1") as file: file.write("\n".join(self)) return @@ -511,6 +890,76 @@ def aspirate_well( ) return + def evo_aspirate_well( + self, + *, + labware: liquidhandling.Labware, + wells: typing.Union[str, typing.List[str]], + labware_position: typing.Tuple[int, int], + volume: typing.Union[float, typing.List[float], int], + liquid_class: str, + tips: typing.Union[typing.List[Tip], typing.List[int]], + ) -> None: + """Command for aspirating with the EvoWARE aspirate command. As many wells in one column may be selected as your liquid handling arm has pipettes. + This method generates the full command (as can be observed when opening a .esc file with an editor) and calls upon other functions to create the code string + specifying the target wells. + + Parameters + ---------- + labware : liquidhandling.Labware + Source labware + wells : list of str + List with target well ID(s) + labware_position : tuple + Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) + volume : int, float or list + Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases + liquid_class : str, optional + Overwrites the liquid class for this step (max 32 characters) + tips : list + Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 + """ + # perform consistency checks + kwargs = dict( + labware=labware, + wells=wells, + labware_position=labware_position, + volume=volume, + liquid_class=liquid_class, + tips=tips, + ) + ( + labware, + wells, + labware_position, + volume, + liquid_class, + tips, + ) = _prepare_evo_aspirate_dispense_parameters(**kwargs, max_volume=self.max_volume) + + # calculate tip_selection based on tips argument (tips are converted to evotools.Tip in _prepare_evo_aspirate_dispense_parameters) + tip_selection = 0 + for tip in tips: + tip_selection += tip.value + + # prepare volume section (volume is converted to list in _prepare_evo_aspirate_dispense_parameters) + tip_volumes = "" + for tip in [1, 2, 4, 8, 16, 32, 64, 128]: + if tip in [tecantip.value for tecantip in tips]: + tip_volumes += f'"{volume[0]}",' + volume.pop(0) + else: + tip_volumes += "0," + + # convert selection from list of well ids to numpy array with same dimensions as target labware (1: well is selected, 0: well is not selected) + selected = evo_make_selection_array(labware.n_rows, labware.n_columns, wells) + # create code string containing information about target well(s) + code_string = evo_get_selection(labware.n_rows, labware.n_columns, selected) + self.append( + f'B;Aspirate({tip_selection},"{liquid_class}",{tip_volumes}0,0,0,0,{labware_position[0]},{labware_position[1]},1,"{code_string}",0,0);' + ) + return + def dispense_well( self, rack_label: str, @@ -579,6 +1028,168 @@ def dispense_well( ) return + def evo_dispense_well( + self, + *, + labware: liquidhandling.Labware, + wells: typing.Union[str, typing.List[str]], + labware_position: typing.Tuple[int, int], + volume: typing.Union[float, typing.List[float], int], + liquid_class: str, + tips: typing.Union[typing.List[Tip], typing.List[int]], + ) -> None: + """Command for dispensing using the EvoWARE dispense command. As many wells in one column may be selected as your liquid handling arm has pipettes. + This method generates the full command (as can be observed when opening a .esc file with an editor) and calls upon other functions to create the code string + specifying the target wells. + + Parameters + ---------- + labware : liquidhandling.Labware + Source labware + wells : list of str + List with target well ID(s) + labware_position : tuple + Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) + volume : int, float or list + Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases + liquid_class : str, optional + Overwrites the liquid class for this step (max 32 characters) + tips : list + Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 + """ + # perform consistency checks + kwargs = dict( + labware=labware, + wells=wells, + labware_position=labware_position, + volume=volume, + liquid_class=liquid_class, + tips=tips, + ) + ( + labware, + wells, + labware_position, + volume, + liquid_class, + tips, + ) = _prepare_evo_aspirate_dispense_parameters(**kwargs, max_volume=self.max_volume) + + # calculate tip_selection based on tips argument (tips are converted to evotools.Tip in _prepare_evo_aspirate_dispense_parameters) + tip_selection = 0 + for tip in tips: + tip_selection += tip.value + + # prepare volume section (volume is converted to list in _prepare_evo_aspirate_dispense_parameters) + tip_volumes = "" + for tip in [1, 2, 4, 8, 16, 32, 64, 128]: + if tip in [tecantip.value for tecantip in tips]: + tip_volumes += f'"{volume[0]}",' + volume.pop(0) + else: + tip_volumes += "0," + + # convert selection from list of well ids to numpy array with same dimensions as target labware (1: well is selected, 0: well is not selected) + selected = evo_make_selection_array(labware.n_rows, labware.n_columns, wells) + # create code string containing information about target well(s) + code_string = evo_get_selection(labware.n_rows, labware.n_columns, selected) + self.append( + f'B;Dispense({tip_selection},"{liquid_class}",{tip_volumes}0,0,0,0,{labware_position[0]},{labware_position[1]},1,"{code_string}",0,0);' + ) + return + + def evo_wash( + self, + *, + tips: typing.Union[typing.List[Tip], typing.List[int]], + waste_location: typing.Tuple[int, int], + cleaner_location: typing.Tuple[int, int], + arm: int = 0, + waste_vol: float = 3.0, + waste_delay: int = 500, + cleaner_vol: float = 4.0, + cleaner_delay: int = 500, + airgap: int = 10, + airgap_speed: int = 70, + retract_speed: int = 30, + fastwash: int = 1, + low_volume: int = 0, + ) -> None: + """Command for aspirating with the EvoWARE aspirate command. As many wells in one column may be selected as your liquid handling arm has pipettes. + This method generates the full command (as can be observed when opening a .esc file with an editor) and calls upon other functions to create the code string + specifying the target wells. + + Parameters + ---------- + tips : list + Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 + waste_location : tuple + Tuple with grid position (1-67) and site number (0-127) of waste as integers + cleaner_location : tuple + Tuple with grid position (1-67) and site number (0-127) of cleaner as integers + arm : int + number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2 + waste_vol: float + Volume in waste in mL (0-100) + waste_delay : int + Delay before closing valves in waste in ms (0-1000) + cleaner_vol: float + Volume in cleaner in mL (0-100) + cleaner_delay : int + Delay before closing valves in cleaner in ms (0-1000) + airgap : int + Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100) + airgap_speed : int + Speed of airgap aspiration in µL/s (1-1000) + retract_speed : int + Retract speed in mm/s (1-100) + fastwash : int + Use fast-wash module = 1, don't use it = 0 + low_volume : int + Use pinch valves = 1, don't use them = 0 + """ + + # perform consistency checks + kwargs = dict( + tips=tips, + waste_location=waste_location, + cleaner_location=cleaner_location, + arm=arm, + waste_vol=waste_vol, + waste_delay=waste_delay, + cleaner_vol=cleaner_vol, + cleaner_delay=cleaner_delay, + airgap=airgap, + airgap_speed=airgap_speed, + retract_speed=retract_speed, + fastwash=fastwash, + low_volume=low_volume, + ) + ( + tips, + waste_location, + cleaner_location, + arm, + waste_vol, + waste_delay, + cleaner_vol, + cleaner_delay, + airgap, + airgap_speed, + retract_speed, + fastwash, + low_volume, + ) = _prepare_evo_wash_parameters(**kwargs) + # calculate tip_selection based on tips argument + tip_selection = 0 + for tip in tips: + tip_selection += tip.value + + self.append( + f'B;Wash({tip_selection},{waste_location[0]},{waste_location[1]},{cleaner_location[0]},{cleaner_location[1]},"{waste_vol}",{waste_delay},"{cleaner_vol}",{cleaner_delay},{airgap},{airgap_speed},{retract_speed},{fastwash},{low_volume},1000,{arm});' + ) + return + def reagent_distribution( self, src_rack_label: str, @@ -734,6 +1345,52 @@ def aspirate( self.aspirate_well(labware.name, labware.positions[well], volume, **kwargs) return + def evo_aspirate( + self, + labware: liquidhandling.Labware, + wells: typing.Union[str, typing.List[str]], + labware_position: typing.Tuple[int, int], + tips: typing.Union[typing.List[Tip], typing.List[int]], + volumes: typing.Union[float, typing.List[float]], + liquid_class: str, + *, + label: typing.Optional[str] = None, + ) -> None: + """Performs aspiration from the provided labware. Is identical to the aspirate command inside the EvoWARE. + Thus, several wells in a single column can be targeted. + + Parameters + ---------- + labware : liquidhandling.Labware + Source labware + labware_position : tuple + Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) + wells : list of str or iterable + List with target well ID(s) + tips : list + Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 + volumes : float or iterable + Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases + liquid_class : str, optional + Overwrites the liquid class for this step (max 32 characters) + """ + # diferentiate between what is needed for volume calculation and for pipetting commands + wells_calc = numpy.array(wells).flatten("F") + volumes_calc = numpy.array(volumes).flatten("F") + if len(volumes_calc) == 1: + volumes_calc = numpy.repeat(volumes_calc, len(wells_calc)) + labware.remove(wells_calc, volumes_calc, label) + self.comment(label) + self.evo_aspirate_well( + labware=labware, + wells=wells, + labware_position=labware_position, + volume=volumes, + liquid_class=liquid_class, + tips=tips, + ) + return + def dispense( self, labware: liquidhandling.Labware, @@ -774,6 +1431,52 @@ def dispense( self.dispense_well(labware.name, labware.positions[well], volume, **kwargs) return + def evo_dispense( + self, + labware: liquidhandling.Labware, + wells: typing.Union[str, typing.List[str]], + labware_position: typing.Tuple[int, int], + tips: typing.Union[typing.List[Tip], typing.List[int]], + volumes: typing.Union[float, typing.List[float]], + liquid_class: str, + *, + label: typing.Optional[str] = None, + ) -> None: + """Performs dispensation from the provided labware. Is identical to the dispense command inside the EvoWARE. + Thus, several wells in a single column can be targeted. + + Parameters + ---------- + labware : liquidhandling.Labware + Source labware + labware_position : tuple + Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) + wells : list of str or iterable + List with target well ID(s) + tips : list + Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 + volumes : float or iterable + Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases + liquid_class : str, optional + Overwrites the liquid class for this step (max 32 characters) + """ + # diferentiate between what is needed for volume calculation and for pipetting commands + wells_calc = numpy.array(wells).flatten("F") + volumes_calc = numpy.array(volumes).flatten("F") + if len(volumes_calc) == 1: + volumes_calc = numpy.repeat(volumes_calc, len(wells_calc)) + labware.remove(wells_calc, volumes_calc, label) + self.comment(label) + self.evo_dispense_well( + labware=labware, + wells=wells, + labware_position=labware_position, + volume=volumes, + liquid_class=liquid_class, + tips=tips, + ) + return + def transfer( self, source: liquidhandling.Labware, diff --git a/robotools/tests.py b/robotools/tests.py index d5498dd..c411604 100644 --- a/robotools/tests.py +++ b/robotools/tests.py @@ -465,6 +465,577 @@ def test_parameter_validation(self) -> None: evotools._prepare_aspirate_dispense_parameters( rack_label="WaterTrough", position=1, volume=15, forced_rack_type="valid forced rack type" ) + + # test _prepare_evo_aspirate_dispense_parameters + # define a labware correctly for testing purposes + plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000) + # test labware argument checks + with pytest.raises(ValueError, match="Invalid labware:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware="wrong_labware_type", + wells=["A01", "B01"], + labware_position=(38, 2), + volume=15, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + # test wells argument checks + with pytest.raises(ValueError, match="Invalid wells:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells="A01", + labware_position=(38, 2), + volume=15, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + # test labware_position argument checks + with pytest.raises(ValueError, match="Invalid position:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, -1), + volume=15, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + with pytest.raises(ValueError, match="Invalid position:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=("a", 2), + volume=15, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + # test liquid_class argument checks + with pytest.raises(ValueError, match="Invalid liquid_class:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume=15, + liquid_class=["Water_DispZmax-1_AspZmax-1"], + tips=[1, 2], + ) + with pytest.raises(ValueError, match="Invalid liquid_class:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume=15, + liquid_class="Water;DispZmax-1;AspZmax-1", + tips=[1, 2], + ) + # test tips argument checks + with pytest.raises(ValueError, match="Invalid type of tips:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume=15, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, "2"], + ) + _, _, _, _, _, tips = evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume=15, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + if not all(isinstance(n, evotools.Tip) for n in tips): + raise TypeError( + f"Even after completing the _prepare_evo_aspirate_dispense_parameters method, not all tips are type Tip." + ) + # test volume argument checks + with pytest.raises(ValueError, match="Invalid volume:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume="volume", + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + with pytest.raises(ValueError, match="Invalid volume:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume=-10, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + with pytest.raises(ValueError, match="Invalid volume:"): + evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["A01", "B01"], + labware_position=(38, 2), + volume=7158279, + liquid_class="Water_DispZmax-1_AspZmax-1", + tips=[1, 2], + ) + + # test complete _prepare_evo_aspirate_dispense_parameters() command + ( + labware, + wells, + labware_position, + volume, + liquid_class, + tips, + ) = evotools._prepare_evo_aspirate_dispense_parameters( + labware=plate, + wells=["E01", "F01", "G01"], + labware_position=(38, 2), + volume=750, + liquid_class="Water_DispZmax_AspZmax", + tips=[5, 6, 7], + ) + self.assertEqual( + [labware, wells, labware_position, volume, liquid_class, tips], + [ + plate, + ["E01", "F01", "G01"], + (38, 2), + [750.0, 750.0, 750.0], + "Water_DispZmax_AspZmax", + [evotools.Tip.T5, evotools.Tip.T6, evotools.Tip.T7], + ], + ) + + # test _prepare_evo_wash_parameters + # test tips argument checks + tips, _, _, _, _, _, _, _, _, _, _, _, _ = evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + ) + if not all(isinstance(n, evotools.Tip) for n in tips): + raise TypeError( + f"Even after completing the _prepare_evo_aspirate_dispense_parameters method, not all tips are type Tip." + ) + + # test waste_location argument checks + with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(68, 1), + cleaner_location=(52, 0), + ) + with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(0, 1), + cleaner_location=(52, 0), + ) + with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(1.7, 1), + cleaner_location=(52, 0), + ) + with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, -1), + cleaner_location=(52, 0), + ) + with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 128), + cleaner_location=(52, 0), + ) + with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1.7), + cleaner_location=(52, 0), + ) + + # test cleaner_location argument checks + with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(68, 1), + ) + with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(0, 1), + ) + with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(1.7, 1), + ) + with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, -1), + ) + with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 128), + ) + with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 1.7), + ) + + # test arm argument check + with pytest.raises(ValueError, match="Parameter arm"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + arm=2, + ) + + # test waste_vol argument check + with pytest.raises(ValueError, match="waste_vol has to be a float"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + waste_vol=-1.0, + ) + with pytest.raises(ValueError, match="waste_vol has to be a float"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + waste_vol=101.0, + ) + with pytest.raises(ValueError, match="waste_vol has to be a float"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + waste_vol=1, + ) + + # test waste_delay argument check + with pytest.raises(ValueError, match="waste_delay has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + waste_delay=-1, + ) + with pytest.raises(ValueError, match="waste_delay has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + waste_delay=1001, + ) + with pytest.raises(ValueError, match="waste_delay has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + waste_delay=10.0, + ) + + # test cleaner_vol argument check + with pytest.raises(ValueError, match="cleaner_vol has to be a float"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + cleaner_vol=-1.0, + ) + with pytest.raises(ValueError, match="cleaner_vol has to be a float"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + cleaner_vol=101.0, + ) + with pytest.raises(ValueError, match="cleaner_vol has to be a float"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + cleaner_vol=1, + ) + + # test cleaner_delay argument check + with pytest.raises(ValueError, match="cleaner_delay has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + cleaner_delay=-1, + ) + with pytest.raises(ValueError, match="cleaner_delay has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + cleaner_delay=1001, + ) + with pytest.raises(ValueError, match="cleaner_delay has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + cleaner_delay=10.0, + ) + + # test airgap argument check + with pytest.raises(ValueError, match="airgap has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + airgap=-1, + ) + with pytest.raises(ValueError, match="airgap has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + airgap=101, + ) + with pytest.raises(ValueError, match="airgap has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + airgap=10.0, + ) + + # test airgap_speed argument check + with pytest.raises(ValueError, match="airgap_speed has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + airgap_speed=0, + ) + with pytest.raises(ValueError, match="airgap_speed has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + airgap_speed=1001, + ) + with pytest.raises(ValueError, match="airgap_speed has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + airgap_speed=10.0, + ) + + # test retract_speed argument check + with pytest.raises(ValueError, match="retract_speed has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + retract_speed=0, + ) + with pytest.raises(ValueError, match="retract_speed has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + retract_speed=101, + ) + with pytest.raises(ValueError, match="retract_speed has to be an int"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + retract_speed=10.0, + ) + + # test fastwash argument check + with pytest.raises(ValueError, match="Parameter fastwash"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + fastwash=2, + ) + with pytest.raises(ValueError, match="Parameter fastwash"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + fastwash=1.0, + ) + + # test low_volume argument check + with pytest.raises(ValueError, match="Parameter low_volume"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + low_volume=2, + ) + with pytest.raises(ValueError, match="Parameter low_volume"): + evotools._prepare_evo_wash_parameters( + tips=[1, 2], + waste_location=(52, 1), + cleaner_location=(52, 0), + low_volume=1.0, + ) + + # test complete _prepare_evo_wash_parameters() command + ( + tips, + waste_location, + cleaner_location, + arm, + waste_vol, + waste_delay, + cleaner_vol, + cleaner_delay, + airgap, + airgap_speed, + retract_speed, + fastwash, + low_volume, + ) = evotools._prepare_evo_wash_parameters( + tips=[1, 2, 3, 4, 5, 6, 7, 8], + waste_location=(52, 1), + cleaner_location=(52, 0), + ) + self.assertEqual( + [ + tips, + waste_location, + cleaner_location, + arm, + waste_vol, + waste_delay, + cleaner_vol, + cleaner_delay, + airgap, + airgap_speed, + retract_speed, + fastwash, + low_volume, + ], + [ + [ + evotools.Tip.T1, + evotools.Tip.T2, + evotools.Tip.T3, + evotools.Tip.T4, + evotools.Tip.T5, + evotools.Tip.T6, + evotools.Tip.T7, + evotools.Tip.T8, + ], + (52, 1), + (52, 0), + 0, + 3.0, + 500, + 4.0, + 500, + 10, + 70, + 30, + 1, + 0, + ], + ) + + return + + def test_evo_aspirate1(self) -> None: + plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000) + with evotools.Worklist() as wl: + wl.evo_aspirate( + labware=plate, + wells=["E01", "F01", "G01"], + labware_position=(38, 2), + tips=[5, 6, 7], + volumes=750, + liquid_class="Water_DispZmax_AspZmax", + ) + self.assertEqual( + wl[0], + 'B;Aspirate(112,"Water_DispZmax_AspZmax",0,0,0,0,"750.0","750.0","750.0",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);', + ) + return + + def test_evo_aspirate2(self) -> None: + plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000) + with evotools.Worklist() as wl: + wl.evo_aspirate( + labware=plate, + wells=["E01", "F01", "G01"], + labware_position=(38, 2), + tips=[5, 6, 7], + volumes=[750, 730, 710], + liquid_class="Water_DispZmax_AspZmax", + ) + self.assertEqual( + wl[0], + 'B;Aspirate(112,"Water_DispZmax_AspZmax",0,0,0,0,"750","730","710",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);', + ) + return + + def test_evo_dispense1(self) -> None: + plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000) + with evotools.Worklist() as wl: + wl.evo_dispense( + labware=plate, + wells=["E01", "F01", "G01"], + labware_position=(38, 2), + tips=[5, 6, 7], + volumes=750, + liquid_class="Water_DispZmax_AspZmax", + ) + self.assertEqual( + wl[0], + 'B;Dispense(112,"Water_DispZmax_AspZmax",0,0,0,0,"750.0","750.0","750.0",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);', + ) + return + + def test_evo_dispense2(self) -> None: + plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000) + with evotools.Worklist() as wl: + wl.evo_dispense( + labware=plate, + wells=["E01", "F01", "G01"], + labware_position=(38, 2), + tips=[5, 6, 7], + volumes=[750, 730, 710], + liquid_class="Water_DispZmax_AspZmax", + ) + self.assertEqual( + wl[0], + 'B;Dispense(112,"Water_DispZmax_AspZmax",0,0,0,0,"750","730","710",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);', + ) + return + + def test_evo_wash(self) -> None: + with evotools.Worklist() as wl: + wl.evo_wash( + tips=[1, 2, 3, 4, 5, 6, 7, 8], + waste_location=(52, 1), + cleaner_location=(52, 0), + ) + self.assertEqual(wl[0], 'B;Wash(255,52,1,52,0,"3.0",500,"4.0",500,10,70,30,1,0,1000,0);') return def test_comment(self) -> None: